diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3c..4827d93ed52 100644 --- a/.coveragerc +++ b/.coveragerc @@ -226,6 +226,7 @@ omit = homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py + homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py @@ -385,7 +386,10 @@ omit = homeassistant/components/foscam/camera.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py + homeassistant/components/freebox/camera.py homeassistant/components/freebox/device_tracker.py + homeassistant/components/freebox/home_base.py + homeassistant/components/freebox/router.py homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py @@ -479,8 +483,6 @@ omit = homeassistant/components/homematic/sensor.py homeassistant/components/homematic/switch.py homeassistant/components/homeworks/* - homeassistant/components/honeywell/__init__.py - homeassistant/components/honeywell/climate.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py homeassistant/components/huawei_lte/__init__.py @@ -831,6 +833,7 @@ omit = homeassistant/components/onvif/event.py homeassistant/components/onvif/parsers.py homeassistant/components/onvif/sensor.py + homeassistant/components/onvif/util.py homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py @@ -938,6 +941,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py @@ -995,6 +999,7 @@ omit = homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py + homeassistant/components/roborock/coordinator.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py @@ -1100,7 +1105,9 @@ omit = homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* + homeassistant/components/snapcast/__init__.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/server.py homeassistant/components/snmp/device_tracker.py homeassistant/components/snmp/sensor.py homeassistant/components/snmp/switch.py @@ -1379,7 +1386,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py homeassistant/components/vesync/sensor.py @@ -1435,7 +1441,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py @@ -1509,7 +1514,7 @@ omit = homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py homeassistant/components/zha/websocket_api.py - homeassistant/components/zha/core/channels/* + homeassistant/components/zha/core/cluster_handlers/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/helpers.py diff --git a/.gitattributes b/.gitattributes index e70ab0a2c70..eca98fc228f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,5 +8,6 @@ *.png binary *.zip binary *.mp3 binary +*.pcm binary Dockerfile.dev linguist-language=Dockerfile diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ff53757bdd6..06a95f4cc9b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,12 +24,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -67,10 +67,10 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -131,7 +131,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e4fd319e715..6fad6573446 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,5 @@ name: CI +run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: {0}', github.ref_name) || '' }}" # yamllint disable-line rule:truthy on: @@ -31,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.4 + HA_SHORT_VERSION: 2023.5 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version @@ -40,7 +41,9 @@ env: # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023) # 10.10 is the latest short-term-support # - 10.10.3 is the latest (as of 6 Feb 2023) - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']" + # mysql 8.0.32 does not always behave the same as MariaDB + # and some queries that work on MariaDB do not work on MySQL + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version @@ -79,7 +82,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,10 +206,10 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -248,9 +251,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -294,9 +297,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -343,9 +346,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -381,9 +384,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -434,6 +437,7 @@ jobs: shell: bash run: | . venv/bin/activate + shopt -s globstar pre-commit run --hook-stage manual prettier --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - name: Register check executables problem matcher @@ -487,10 +491,10 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -539,7 +543,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.1" setuptools wheel + 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 -e . @@ -555,10 +559,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -587,10 +591,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,10 +624,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -664,10 +668,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -730,10 +734,10 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -783,10 +787,10 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -909,10 +913,10 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1017,10 +1021,10 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1091,19 +1095,28 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v3.1.1 + uses: Wandalen/wretry.action@v1.0.36 with: - fail_ci_if_error: true - flags: full-suite + action: codecov/codecov-action@v3.1.3 + with: | + fail_ci_if_error: true + flags: full-suite + attempt_limit: 5 + attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v3.1.1 + uses: Wandalen/wretry.action@v1.0.36 with: - fail_ci_if_error: true + action: codecov/codecov-action@v3.1.3 + with: | + fail_ci_if_error: true + attempt_limit: 5 + attempt_delay: 30000 diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 86bfa5f9bb9..a18c050024b 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 + uses: actions/setup-python@v4.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 63069b86ef8..b6a00492e3d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,10 @@ on: - "requirements.txt" - "requirements_all.txt" +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name}} + cancel-in-progress: true + jobs: init: name: Initialize wheels builder @@ -22,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Get information id: info @@ -72,17 +76,18 @@ jobs: path: ./requirements_diff.txt core: - name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core + name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest strategy: fail-fast: false matrix: + abi: ["cp310", "cp311"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -95,9 +100,9 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} @@ -108,18 +113,19 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations: - name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations + integrations_cp310: + name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest strategy: fail-fast: false matrix: + abi: ["cp310"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.0 + uses: actions/checkout@v3.5.2 - name: Download env_file uses: actions/download-artifact@v3 @@ -135,6 +141,7 @@ 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} @@ -171,30 +178,177 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;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 + 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" - name: Build wheels (part 2) - uses: home-assistant/wheels@2022.10.1 + uses: home-assistant/wheels@2023.04.0 with: - abi: cp310 + abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;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 + 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" + + # Wheels building for the cp311 ABI is currently split + # This is mainly until we have figured out to get all wheels built. + # Without harming our current workflow. + integrations_cp311: + name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} + if: github.repository_owner == 'home-assistant' + needs: init + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: ["cp311"] + arch: ${{ fromJson(needs.init.outputs.architectures) }} + steps: + - name: Checkout the repository + uses: actions/checkout@v3.5.2 + + - name: Write alternative env-file for cp311 + run: | + ( + echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" + echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" + echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" + echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" + + # GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed + # execinfo-dev when building wheels. However, this package is no longer available + # Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed. + echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo + + # Fix out of memory issues with rust + echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" + + # OpenCV headless installation + echo "CI_BUILD=1" + echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" + ) > .env_file + + - name: Download requirements_diff + uses: actions/download-artifact@v3 + with: + name: requirements_diff + + - name: (Un)comment packages + run: | + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + + # PyBluez no longer compiles. Commented it out for now. + # It need further cleanup down the line, as all machine images + # try to install it. + # sed -i "s|# pybluez|pybluez|g" ${requirement_file} + + # 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 + # instances using this integration according to analytics. + # sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} + + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + + # Some packages are not buildable on armhf anymore + if [ "${{ matrix.arch }}" = "armhf" ]; then + + # 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" ${requirement_file} + sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file} + sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} + sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} + fi + done + + - name: Split requirements all + run: | + # We split requirements all into two different files. + # This is to prevent the build from running out of memory when + # resolving packages on 32-bits systems (like armhf, armv7). + + split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt + + - name: Adjust build env + run: | + if [ "${{ matrix.arch }}" = "i386" ]; then + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + # Probably not an issue anymore. Removing for now. + # ( + # # cmake > 3.22.2 have issue on arm + # # Tested until 3.22.5 + # echo "cmake==3.22.2" + # ) >> homeassistant/package_constraints.txt + + # Do not pin numpy in wheels building + sed -i "/numpy/d" homeassistant/package_constraints.txt + + - name: Build wheels (part 1) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + legacy: true + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtaa" + + - name: Build wheels (part 2) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db3..8e8fef97697 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.262 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/.strict-typing b/.strict-typing index 533d5239cab..a5f084116a2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -57,10 +57,12 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* homeassistant.components.aqualogic.* homeassistant.components.aseko_pool_live.* +homeassistant.components.assist_pipeline.* homeassistant.components.asuswrt.* homeassistant.components.auth.* homeassistant.components.automation.* @@ -137,6 +139,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_hardware.* diff --git a/.vscode/launch.json b/.vscode/launch.json index 39b32fd5561..c165e252b1a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "preLaunchTask": "Compile English translations" }, { - // Debug by attaching to local Home Asistant server using Remote Python Debugger. + // Debug by attaching to local Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ "name": "Home Assistant: Attach Local", "type": "python", @@ -38,7 +38,7 @@ ] }, { - // Debug by attaching to remote Home Asistant server using Remote Python Debugger. + // Debug by attaching to remote Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ "name": "Home Assistant: Attach Remote", "type": "python", diff --git a/CODEOWNERS b/CODEOWNERS index f50ff89d05a..e426d5f98b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,6 +80,10 @@ 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/anova/ @Lash-L +/tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex /homeassistant/components/apache_kafka/ @bachya @@ -103,6 +107,8 @@ build.json @home-assistant/supervisor /homeassistant/components/arris_tg2492lg/ @vanbalken /homeassistant/components/aseko_pool_live/ @milanmeu /tests/components/aseko_pool_live/ @milanmeu +/homeassistant/components/assist_pipeline/ @balloob @synesthesiam +/tests/components/assist_pipeline/ @balloob @synesthesiam /homeassistant/components/asuswrt/ @kennedyshead @ollo69 /tests/components/asuswrt/ @kennedyshead @ollo69 /homeassistant/components/atag/ @MatsNL @@ -168,6 +174,8 @@ build.json @home-assistant/supervisor /tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu +/homeassistant/components/brottsplatskartan/ @gjohansson-ST +/tests/components/brottsplatskartan/ @gjohansson-ST /homeassistant/components/brunt/ @eavanvalkenburg /tests/components/brunt/ @eavanvalkenburg /homeassistant/components/bsblan/ @liudger @@ -215,8 +223,6 @@ build.json @home-assistant/supervisor /tests/components/conversation/ @home-assistant/core @synesthesiam /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund -/homeassistant/components/coronavirus/ @home-assistant/core -/tests/components/coronavirus/ @home-assistant/core /homeassistant/components/counter/ @fabaff /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core @@ -281,7 +287,7 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @depl0y @glodenox /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -544,8 +550,8 @@ build.json @home-assistant/supervisor /tests/components/image_processing/ @home-assistant/core /homeassistant/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core -/homeassistant/components/imap/ @engrbm87 -/tests/components/imap/ @engrbm87 +/homeassistant/components/imap/ @engrbm87 @jbouwh +/tests/components/imap/ @engrbm87 @jbouwh /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 @@ -649,8 +655,8 @@ build.json @home-assistant/supervisor /tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner -/homeassistant/components/lifx/ @bdraco @Djelibeybi -/tests/components/lifx/ @bdraco @Djelibeybi +/homeassistant/components/lifx/ @bdraco +/tests/components/lifx/ @bdraco /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linux_battery/ @fabaff @@ -819,8 +825,8 @@ build.json @home-assistant/supervisor /tests/components/numato/ @clssn /homeassistant/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka -/homeassistant/components/nut/ @bdraco @ollo69 -/tests/components/nut/ @bdraco @ollo69 +/homeassistant/components/nut/ @bdraco @ollo69 @pestevez +/tests/components/nut/ @bdraco @ollo69 @pestevez /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nzbget/ @chriscla @@ -893,8 +899,8 @@ build.json @home-assistant/supervisor /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren -/homeassistant/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck -/tests/components/plugwise/ @CoMPaTech @bouwew @brefra @frenck +/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck +/tests/components/plugwise/ @CoMPaTech @bouwew @frenck /homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa /tests/components/plum_lightpad/ @ColinHarrington @prystupa /homeassistant/components/point/ @fredrike @@ -931,6 +937,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte @@ -958,6 +965,8 @@ build.json @home-assistant/supervisor /tests/components/rainmachine/ @bachya /homeassistant/components/random/ @fabaff /tests/components/random/ @fabaff +/homeassistant/components/rapt_ble/ @sairon +/tests/components/rapt_ble/ @sairon /homeassistant/components/raspberry_pi/ @home-assistant/core /tests/components/raspberry_pi/ @home-assistant/core /homeassistant/components/rdw/ @frenck @@ -976,6 +985,8 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @MTrab @ShadowBr0ther +/homeassistant/components/rest/ @epenet +/tests/components/rest/ @epenet /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -990,6 +1001,8 @@ build.json @home-assistant/supervisor /tests/components/rituals_perfume_genie/ @milanmeu /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi +/homeassistant/components/roborock/ @humbertogontijo @Lash-L +/tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington /homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @@ -1102,6 +1115,7 @@ build.json @home-assistant/supervisor /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 +/tests/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck @@ -1130,8 +1144,8 @@ build.json @home-assistant/supervisor /homeassistant/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck /tests/components/spotify/ @frenck -/homeassistant/components/sql/ @dgomes @gjohansson-ST -/tests/components/sql/ @dgomes @gjohansson-ST +/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira +/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud /tests/components/squeezebox/ @rajlaud /homeassistant/components/srp_energy/ @briglx @@ -1153,8 +1167,8 @@ build.json @home-assistant/supervisor /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /tests/components/stream/ @hunterjm @uvjustin @allenporter -/homeassistant/components/stt/ @pvizeli -/tests/components/stt/ @pvizeli +/homeassistant/components/stt/ @home-assistant/core @pvizeli +/tests/components/stt/ @home-assistant/core @pvizeli /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @@ -1249,8 +1263,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins -/homeassistant/components/tts/ @pvizeli -/tests/components/tts/ @pvizeli +/homeassistant/components/tts/ @home-assistant/core @pvizeli +/tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck @@ -1262,8 +1276,8 @@ build.json @home-assistant/supervisor /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 /homeassistant/components/unifiled/ @florisvdk -/homeassistant/components/unifiprotect/ @briis @AngellusMortis @bdraco -/tests/components/unifiprotect/ @briis @AngellusMortis @bdraco +/homeassistant/components/unifiprotect/ @AngellusMortis @bdraco +/tests/components/unifiprotect/ @AngellusMortis @bdraco /homeassistant/components/upb/ @gwww /tests/components/upb/ @gwww /homeassistant/components/upc_connect/ @pvizeli @fabaff @@ -1299,8 +1313,6 @@ build.json @home-assistant/supervisor /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/homeassistant/components/vicare/ @oischinger -/tests/components/vicare/ @oischinger /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel @@ -1308,8 +1320,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare -/homeassistant/components/voice_assistant/ @balloob @synesthesiam -/tests/components/voice_assistant/ @balloob @synesthesiam +/homeassistant/components/voip/ @balloob @synesthesiam +/tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @@ -1361,9 +1373,10 @@ build.json @home-assistant/supervisor /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger +/homeassistant/components/wyoming/ @balloob @synesthesiam +/tests/components/wyoming/ @balloob @synesthesiam /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 09828047616..fab04fe3972 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -132,8 +132,8 @@ For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . -[coc-blog]: /blog/2017/01/21/home-assistant-governance/ -[coc2-blog]: /blog/2020/05/25/code-of-conduct-updated/ +[coc-blog]: https://www.home-assistant.io/blog/2017/01/21/home-assistant-governance/ +[coc2-blog]: https://www.home-assistant.io/blog/2020/05/25/code-of-conduct-updated/ [email]: mailto:safety@home-assistant.io [homepage]: http://contributor-covenant.org [mozilla]: https://github.com/mozilla/diversity diff --git a/Dockerfile.dev b/Dockerfile.dev index 116446d1818..336648ae1c2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,11 +4,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions -RUN pipx uninstall black -RUN pipx uninstall pydocstyle -RUN pipx uninstall pycodestyle -RUN pipx uninstall mypy -RUN pipx uninstall pylint +RUN \ + pipx uninstall black \ + && pipx uninstall pydocstyle \ + && pipx uninstall pycodestyle \ + && pipx uninstall mypy \ + && pipx uninstall pylint RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ diff --git a/build.yaml b/build.yaml index 22b93014974..0bc38d72269 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.02.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.02.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.02.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.02.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.02.0 + 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 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png index 23202a578f1..bc304f11b16 100644 Binary files a/docs/screenshot-integrations.png and b/docs/screenshot-integrations.png differ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d98680c70d4..2077274be55 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -629,6 +629,9 @@ async def _async_set_up_integrations( - stage_1_domains ) + # Enables after dependencies when setting up stage 1 domains + async_set_domains_to_be_loaded(hass, stage_1_domains) + # Start setup if stage_1_domains: _LOGGER.info("Setting up stage 1: %s", stage_1_domains) @@ -640,7 +643,7 @@ async def _async_set_up_integrations( except asyncio.TimeoutError: _LOGGER.warning("Setup timed out for stage 1 - moving forward") - # Enables after dependencies + # Add after dependencies when setting up stage 2 domains async_set_domains_to_be_loaded(hass, stage_2_domains) if stage_2_domains: diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a6..9da24e76f19 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f873..c7943d15bd0 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -10,14 +10,15 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -49,6 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Remove ozone sensors from registry if they exist + ent_reg = er.async_get(hass) + for day in range(0, 5): + unique_id = f"{coordinator.location_key}-ozone-{day}" + if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id): + _LOGGER.debug("Removing ozone sensor entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True @@ -116,11 +125,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f415..87bc8eaef89 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5b0951dde97..ad07154ff6b 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.0"] + "requirements": ["accuweather==0.5.1"] } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418c..5d0c70de4e1 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,18 +59,25 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( + AccuWeatherSensorDescription( + key="AirQuality", + icon="mdi:air-filter", + name="Air quality", + value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + device_class=SensorDeviceClass.ENUM, + options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + translation_key="air_quality", + ), AccuWeatherSensorDescription( key="CloudCoverDay", icon="mdi:weather-cloudy", name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +85,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,15 +93,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="grass_pollen", ), AccuWeatherSensorDescription( key="HoursOfSun", icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,16 +110,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - ), - AccuWeatherSensorDescription( - key="Ozone", - icon="mdi:vector-triangle", - name="Ozone", - entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="mold_pollen", ), AccuWeatherSensorDescription( key="Ragweed", @@ -121,56 +120,53 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="ragweed_pollen", ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,25 +174,26 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="tree_pollen", ), AccuWeatherSensorDescription( key="UVIndex", icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="uv_index", ), AccuWeatherSensorDescription( key="WindGustDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +201,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +230,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +239,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +250,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +258,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +275,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +294,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +302,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +311,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +320,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +337,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +358,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +397,27 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +435,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d37b5a10776..e9c4ace9b99 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -30,6 +30,91 @@ "rising": "Rising", "falling": "Falling" } + }, + "air_quality": { + "state": { + "good": "Good", + "hazardous": "Hazardous", + "high": "High", + "low": "Low", + "moderate": "Moderate", + "unhealthy": "Unhealthy" + } + }, + "grass_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "mold_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "tree_pollen": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } + }, + "uv_index": { + "state_attributes": { + "level": { + "name": "Level", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } + } } } }, diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad5..0ef729b9b69 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ class AccuWeatherEntity( # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ class AccuWeatherEntity( @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ class AccuWeatherEntity( @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,19 +107,7 @@ class AccuWeatherEntity( @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) - - @property - def ozone(self) -> int | None: - """Return the ozone level.""" - # We only have ozone data for certain locations and only in the forecast data. - if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get( - "Ozone" - ): - return cast(int, self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"]) - return None + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def forecast(self) -> list[Forecast] | None: diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 739baefda5d..4dbc2edad8d 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -7,11 +7,11 @@ from advantage_air import ApiError, advantage_air from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN +from .models import AdvantageAirData ADVANTAGE_AIR_SYNC_INTERVAL = 15 PLATFORMS = [ @@ -53,29 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), ) - def error_handle_factory(func): - """Return the provided API function wrapped. - - Adds an error handler and coordinator refresh. - """ - - async def error_handle(param): - try: - if await func(param): - await coordinator.async_refresh() - except ApiError as err: - raise HomeAssistantError(err) from err - - return error_handle - await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, - "aircon": error_handle_factory(api.aircon.async_set), - "lights": error_handle_factory(api.lights.async_set), - } + hass.data[DOMAIN][entry.entry_id] = AdvantageAirData(coordinator, api) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 4cecffe2dd1..74a276dc67b 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -1,8 +1,6 @@ """Binary Sensor platform for Advantage Air integration.""" from __future__ import annotations -from typing import Any - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -14,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -25,10 +24,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir Binary Sensor platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[BinarySensorEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirFilter(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): @@ -48,7 +47,7 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Filter" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air Filter sensor.""" super().__init__(instance, ac_key) self._attr_unique_id += "-filter" @@ -64,7 +63,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Motion sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} motion' @@ -82,7 +81,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} myZone' diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 53a41994fc6..a13fa95f6ba 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -5,6 +5,8 @@ import logging from typing import Any from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -26,24 +28,17 @@ from .const import ( DOMAIN as ADVANTAGE_AIR_DOMAIN, ) from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData ADVANTAGE_AIR_HVAC_MODES = { "heat": HVACMode.HEAT, "cool": HVACMode.COOL, "vent": HVACMode.FAN_ONLY, "dry": HVACMode.DRY, - "myauto": HVACMode.AUTO, + "myauto": HVACMode.HEAT_COOL, } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -AC_HVAC_MODES = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, -] - ADVANTAGE_AIR_FAN_MODES = { "autoAA": FAN_AUTO, "low": FAN_LOW, @@ -53,7 +48,14 @@ ADVANTAGE_AIR_FAN_MODES = { HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} -ZONE_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT_COOL] +ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_MYZONE = "MyZone" +ADVANTAGE_AIR_MYAUTO = "MyAuto" +ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" +ADVANTAGE_AIR_MYTEMP = "MyTemp" +ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" +ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" +ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" PARALLEL_UPDATES = 0 @@ -67,15 +69,15 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir climate platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[ClimateEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirAC(instance, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add zone climate control when zone is in temperature control - if zone["type"] != 0: + if zone["type"] > 0: entities.append(AdvantageAirZone(instance, ac_key, zone_key)) async_add_entities(entities) @@ -83,24 +85,56 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] - _attr_hvac_modes = AC_HVAC_MODES - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) - if self._ac.get("myAutoModeEnabled"): - self._attr_hvac_modes = AC_HVAC_MODES + [HVACMode.AUTO] + + # Set supported features and HVAC modes based on current operating mode + if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): + # MyAuto + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT_COOL, + ] + elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): + # MyTemp + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] + + else: + # MyZone + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] + + # Add "ezfan" mode if supported + if self._ac.get(ADVANTAGE_AIR_AUTOFAN): + self._attr_fan_modes += [FAN_AUTO] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the current target temperature.""" return self._ac["setTemp"] @@ -116,77 +150,71 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + @property + def target_temperature_high(self) -> float | None: + """Return the temperature cool mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_COOL_TARGET) + + @property + def target_temperature_low(self) -> float | None: + """Return the temperature heat mode is enabled.""" + return self._ac.get(ADVANTAGE_AIR_HEAT_TARGET) + async def async_turn_on(self) -> None: """Set the HVAC State to on.""" - await self.aircon( - { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_ON, - } - } - } - ) + await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_ON}) async def async_turn_off(self) -> None: """Set the HVAC State to off.""" - await self.aircon( + await self.async_update_ac( { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_OFF, - } - } + "state": ADVANTAGE_AIR_STATE_OFF, } ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - await self.aircon( - {self.ac_key: {"info": {"state": ADVANTAGE_AIR_STATE_OFF}}} - ) + await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF}) else: - await self.aircon( + await self.async_update_ac( { - self.ac_key: { - "info": { - "state": ADVANTAGE_AIR_STATE_ON, - "mode": HASS_HVAC_MODES.get(hvac_mode), - } - } + "state": ADVANTAGE_AIR_STATE_ON, + "mode": HASS_HVAC_MODES.get(hvac_mode), } ) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" - await self.aircon( - {self.ac_key: {"info": {"fan": HASS_FAN_MODES.get(fan_mode)}}} - ) + await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)}) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" - temp = kwargs.get(ATTR_TEMPERATURE) - await self.aircon({self.ac_key: {"info": {"setTemp": temp}}}) + if ATTR_TEMPERATURE in kwargs: + await self.async_update_ac({"setTemp": kwargs[ATTR_TEMPERATURE]}) + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + await self.async_update_ac( + { + ADVANTAGE_AIR_COOL_TARGET: kwargs[ATTR_TARGET_TEMP_HIGH], + ADVANTAGE_AIR_HEAT_TARGET: kwargs[ATTR_TARGET_TEMP_LOW], + } + ) class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): - """AdvantageAir Zone control.""" + """AdvantageAir MyTemp Zone control.""" + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _attr_hvac_modes = ZONE_HVAC_MODES - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" super().__init__(instance, ac_key, zone_key) self._attr_name = self._zone["name"] - self._attr_unique_id = ( - f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' - ) @property def hvac_mode(self) -> HVACMode: @@ -196,7 +224,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.OFF @property - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone["measuredTemp"] @@ -207,23 +235,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): async def async_turn_on(self) -> None: """Set the HVAC State to on.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_OPEN}) async def async_turn_off(self) -> None: """Set the HVAC State to off.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" @@ -235,4 +251,4 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) - await self.aircon({self.ac_key: {"zones": {self.zone_key: {"setTemp": temp}}}}) + await self.async_update_zone({"setTemp": temp}) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 8d05f7e2e63..afb38dee931 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -16,7 +16,8 @@ from .const import ( ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN, ) -from .entity import AdvantageAirZoneEntity +from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -28,15 +29,25 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir cover platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[CoverEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): for zone_key, zone in ac_device["zones"].items(): # Only add zone vent controls when zone in vent control mode. if zone["type"] == 0: entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2" + entities.append( + AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) + ) + elif thing["channelDipState"] == 3: # 3 = "Garage door" + entities.append( + AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) + ) async_add_entities(entities) @@ -50,7 +61,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent.""" super().__init__(instance, ac_key, zone_key) self._attr_name = self._zone["name"] @@ -69,47 +80,52 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Fully open zone vent.""" - await self.aircon( - { - self.ac_key: { - "zones": { - self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100} - } - } - } + await self.async_update_zone( + {"state": ADVANTAGE_AIR_STATE_OPEN, "value": 100}, ) async def async_close_cover(self, **kwargs: Any) -> None: """Fully close zone vent.""" - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) async def async_set_cover_position(self, **kwargs: Any) -> None: """Change vent position.""" position = round(kwargs[ATTR_POSITION] / 5) * 5 if position == 0: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_update_zone({"state": ADVANTAGE_AIR_STATE_CLOSE}) else: - await self.aircon( + await self.async_update_zone( { - self.ac_key: { - "zones": { - self.zone_key: { - "state": ADVANTAGE_AIR_STATE_OPEN, - "value": position, - } - } - } + "state": ADVANTAGE_AIR_STATE_OPEN, + "value": position, } ) + + +class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity): + """Representation of Advantage Air Cover controlled by MyPlace.""" + + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + instance: AdvantageAirData, + thing: dict[str, Any], + device_class: CoverDeviceClass, + ) -> None: + """Initialize an Advantage Air Things Cover.""" + super().__init__(instance, thing) + self._attr_device_class = device_class + + @property + def is_closed(self) -> bool: + """Return if cover is fully closed.""" + return self._data["value"] == 0 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Fully open zone vent.""" + return await self.async_turn_on() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Fully close zone vent.""" + return await self.async_turn_off() diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index aaaa4ff5813..bbc8738c4ae 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -1,11 +1,14 @@ """Advantage Air parent entity class.""" - from typing import Any +from advantage_air import ApiError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .models import AdvantageAirData class AdvantageAirEntity(CoordinatorEntity): @@ -13,19 +16,34 @@ class AdvantageAirEntity(CoordinatorEntity): _attr_has_entity_name = True - def __init__(self, instance: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData) -> None: """Initialize common aspects of an Advantage Air entity.""" - super().__init__(instance["coordinator"]) + super().__init__(instance.coordinator) self._attr_unique_id: str = self.coordinator.data["system"]["rid"] + def update_handle_factory(self, func, *keys): + """Return the provided API function wrapped. + + Adds an error handler and coordinator refresh, and presets keys. + """ + + async def update_handle(*values): + try: + if await func(*keys, *values): + await self.coordinator.async_refresh() + except ApiError as err: + raise HomeAssistantError(err) from err + + return update_handle + class AdvantageAirAcEntity(AdvantageAirEntity): """Parent class for Advantage Air AC Entities.""" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize common aspects of an Advantage Air ac entity.""" super().__init__(instance) - self.aircon = instance["aircon"] + self.ac_key: str = ac_key self._attr_unique_id += f"-{ac_key}" @@ -36,6 +54,9 @@ class AdvantageAirAcEntity(AdvantageAirEntity): model=self.coordinator.data["system"]["sysType"], name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"], ) + self.async_update_ac = self.update_handle_factory( + instance.api.aircon.async_update_ac, self.ac_key + ) @property def _ac(self) -> dict[str, Any]: @@ -45,12 +66,56 @@ class AdvantageAirAcEntity(AdvantageAirEntity): class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize common aspects of an Advantage Air zone entity.""" super().__init__(instance, ac_key) + self.zone_key: str = zone_key self._attr_unique_id += f"-{zone_key}" + self.async_update_zone = self.update_handle_factory( + instance.api.aircon.async_update_zone, self.ac_key, self.zone_key + ) @property def _zone(self) -> dict[str, Any]: return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key] + + +class AdvantageAirThingEntity(AdvantageAirEntity): + """Parent class for Advantage Air Things Entities.""" + + def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: + """Initialize common aspects of an Advantage Air Things entity.""" + super().__init__(instance) + + self._id = thing["id"] + self._attr_unique_id += f"-{self._id}" + + self._attr_device_info = DeviceInfo( + via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Advantage Air", + model="MyPlace", + name=thing["name"], + ) + self.async_update_value = self.update_handle_factory( + instance.api.things.async_update_value, self._id + ) + + @property + def _data(self) -> dict: + """Return the thing data.""" + return self.coordinator.data["myThings"]["things"][self._id] + + @property + def is_on(self): + """Return if the thing is considered on.""" + return self._data["value"] > 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the thing on.""" + await self.async_update_value(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the thing off.""" + await self.async_update_value(False) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index f0ae669acde..13a77d5cab3 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -7,12 +7,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - DOMAIN as ADVANTAGE_AIR_DOMAIN, -) -from .entity import AdvantageAirEntity +from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN +from .entity import AdvantageAirEntity, AdvantageAirThingEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -22,15 +19,21 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir light platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[LightEntity] = [] - if my_lights := instance["coordinator"].data.get("myLights"): + if my_lights := instance.coordinator.data.get("myLights"): for light in my_lights["lights"].values(): if light.get("relay"): entities.append(AdvantageAirLight(instance, light)) else: entities.append(AdvantageAirLightDimmable(instance, light)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] == 4: # 4 = "Light (on/off)"" + entities.append(AdvantageAirThingLight(instance, thing)) + elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)"" + entities.append(AdvantageAirThingLightDimmable(instance, thing)) async_add_entities(entities) @@ -39,10 +42,10 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, instance: dict[str, Any], light: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: """Initialize an Advantage Air Light.""" super().__init__(instance) - self.lights = instance["lights"] + self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( @@ -52,24 +55,27 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): model=light.get("moduleType"), name=light["name"], ) + self.async_update_state = self.update_handle_factory( + instance.api.lights.async_update_state, self._id + ) @property - def _light(self) -> dict[str, Any]: + def _data(self) -> dict[str, Any]: """Return the light object.""" return self.coordinator.data["myLights"]["lights"][self._id] @property def is_on(self) -> bool: """Return if the light is on.""" - return self._light["state"] == ADVANTAGE_AIR_STATE_ON + return self._data["state"] == ADVANTAGE_AIR_STATE_ON async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_ON}) + await self.async_update_state(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.lights({"id": self._id, "state": ADVANTAGE_AIR_STATE_OFF}) + await self.async_update_state(False) class AdvantageAirLightDimmable(AdvantageAirLight): @@ -77,14 +83,41 @@ class AdvantageAirLightDimmable(AdvantageAirLight): _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + """Initialize an Advantage Air Dimmable Light.""" + super().__init__(instance, light) + self.async_update_value = self.update_handle_factory( + instance.api.lights.async_update_value, self._id + ) + @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return round(self._light["value"] * 255 / 100) + return round(self._data["value"] * 255 / 100) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on and optionally set the brightness.""" - data: dict[str, Any] = {"id": self._id, "state": ADVANTAGE_AIR_STATE_ON} if ATTR_BRIGHTNESS in kwargs: - data["value"] = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) - await self.lights(data) + return await self.async_update_value(round(kwargs[ATTR_BRIGHTNESS] / 2.55)) + return await self.async_update_state(True) + + +class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity): + """Representation of Advantage Air Light controlled by myThings.""" + + _attr_supported_color_modes = {ColorMode.ONOFF} + + +class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity): + """Representation of Advantage Air Dimmable Light controlled by myThings.""" + + _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return round(self._data["value"] * 255 / 100) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on by setting the brightness.""" + await self.async_update_value(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index 85b093ea739..ed9d3bff989 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["advantage_air"], "quality_scale": "platinum", - "requirements": ["advantage_air==0.4.1"] + "requirements": ["advantage_air==0.4.4"] } diff --git a/homeassistant/components/advantage_air/models.py b/homeassistant/components/advantage_air/models.py new file mode 100644 index 00000000000..f56b3f8823b --- /dev/null +++ b/homeassistant/components/advantage_air/models.py @@ -0,0 +1,16 @@ +"""The Advantage Air integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from advantage_air import advantage_air + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +@dataclass +class AdvantageAirData: + """Data for the Advantage Air integration.""" + + coordinator: DataUpdateCoordinator + api: advantage_air diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index 742ce810011..013f2cc214d 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -1,5 +1,4 @@ """Select platform for Advantage Air integration.""" -from typing import Any from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -8,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity +from .models import AdvantageAirData ADVANTAGE_AIR_INACTIVE = "Inactive" @@ -19,10 +19,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir select platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SelectEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key in aircons: entities.append(AdvantageAirMyZone(instance, ac_key)) async_add_entities(entities) @@ -34,7 +34,7 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): _attr_icon = "mdi:home-thermometer" _attr_name = "MyZone" - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air MyZone control.""" super().__init__(instance, ac_key) self._attr_unique_id += "-myzone" @@ -42,11 +42,12 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE} self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} - for zone in instance["coordinator"].data["aircons"][ac_key]["zones"].values(): - if zone["type"] > 0: - self._name_to_number[zone["name"]] = zone["number"] - self._number_to_name[zone["number"]] = zone["name"] - self._attr_options.append(zone["name"]) + if "aircons" in instance.coordinator.data: + for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values(): + if zone["type"] > 0: + self._name_to_number[zone["name"]] = zone["number"] + self._number_to_name[zone["number"]] = zone["name"] + self._attr_options.append(zone["name"]) @property def current_option(self) -> str: @@ -55,6 +56,4 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set the MyZone.""" - await self.aircon( - {self.ac_key: {"info": {"myZone": self._name_to_number[option]}}} - ) + await self.async_update_ac({"myZone": self._name_to_number[option]}) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 04b3802f643..4af028e6db0 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity +from .models import AdvantageAirData ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes" ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min" @@ -34,10 +35,10 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir sensor platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SensorEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) @@ -65,7 +66,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, action: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None: """Initialize the Advantage Air timer control.""" super().__init__(instance, ac_key) self.action = action @@ -88,7 +89,7 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): async def set_time_to(self, **kwargs: Any) -> None: """Set the timer value.""" value = min(720, max(0, int(kwargs[ADVANTAGE_AIR_SET_COUNTDOWN_VALUE]))) - await self.aircon({self.ac_key: {"info": {self._time_key: value}}}) + await self.async_update_ac({self._time_key: value}) class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): @@ -98,7 +99,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" super().__init__(instance, ac_key, zone_key=zone_key) self._attr_name = f'{self._zone["name"]} vent' @@ -126,7 +127,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} signal' @@ -160,7 +161,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: dict[str, Any], ac_key: str, zone_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} temperature' diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index e3504ab7624..7234ca36305 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -1,7 +1,7 @@ """Switch platform for Advantage Air integration.""" from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -11,7 +11,8 @@ from .const import ( ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN, ) -from .entity import AdvantageAirAcEntity +from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -21,13 +22,17 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir switch platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] entities: list[SwitchEntity] = [] - if aircons := instance["coordinator"].data.get("aircons"): + if aircons := instance.coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": entities.append(AdvantageAirFreshAir(instance, ac_key)) + if things := instance.coordinator.data.get("myThings"): + for thing in things["things"].values(): + if thing["channelDipState"] == 8: # 8 = Other relay + entities.append(AdvantageAirRelay(instance, thing)) async_add_entities(entities) @@ -36,8 +41,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): _attr_icon = "mdi:air-filter" _attr_name = "Fresh air" + _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: dict[str, Any], ac_key: str) -> None: + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an Advantage Air fresh air control.""" super().__init__(instance, ac_key) self._attr_unique_id += "-freshair" @@ -49,12 +55,14 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn fresh air on.""" - await self.aircon( - {self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_ON}}} - ) + await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_ON}) async def async_turn_off(self, **kwargs: Any) -> None: """Turn fresh air off.""" - await self.aircon( - {self.ac_key: {"info": {"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}}} - ) + await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}) + + +class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): + """Representation of Advantage Air Thing.""" + + _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 404fcad7447..a646ba3b521 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -1,5 +1,4 @@ """Advantage Air Update platform.""" -from typing import Any from homeassistant.components.update import UpdateEntity from homeassistant.config_entries import ConfigEntry @@ -9,6 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity +from .models import AdvantageAirData async def async_setup_entry( @@ -18,7 +18,7 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir update platform.""" - instance = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] async_add_entities([AdvantageAirApp(instance)]) @@ -28,7 +28,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): _attr_name = "App" - def __init__(self, instance: dict[str, Any]) -> None: + def __init__(self, instance: AdvantageAirData) -> None: """Initialize the Advantage Air App.""" super().__init__(instance) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b7879270..21be2e5d664 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 120b24dcc44..d7d495b55bf 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HassJob, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -237,7 +237,13 @@ class Alert(Entity): """Schedule a notification.""" delay = self._delay[self._next_delay] next_msg = now() + delay - self._cancel = async_track_point_in_time(self.hass, self._notify, next_msg) + self._cancel = async_track_point_in_time( + self.hass, + HassJob( + self._notify, name="Schedule notify alert", cancel_on_shutdown=True + ), + next_msg, + ) self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) async def _notify(self, *args: Any) -> None: diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 9be7381adb6..3a702421d94 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -60,6 +60,7 @@ class AlexaConfig(AbstractConfig): """Return an identifier for the user that represents this config.""" return "" + @core.callback def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2fd..f68ae3df114 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b4..a423a628367 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff41..b1a13ce9414 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 2542ed5177e..c02c1a3a3b6 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -24,11 +24,23 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: def start_schedule(_event: Event) -> None: """Start the send schedule after the started event.""" # Wait 15 min after started - async_call_later(hass, 900, analytics.send_analytics) + async_call_later( + hass, + 900, + HassJob( + analytics.send_analytics, + name="analytics schedule", + cancel_on_shutdown=True, + ), + ) # Send every day async_track_time_interval( - hass, analytics.send_analytics, INTERVAL, name="analytics daily" + hass, + analytics.send_analytics, + INTERVAL, + name="analytics daily", + cancel_on_shutdown=True, ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da6..4a1ad55e0b1 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6c..7e2b1e85f39 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e1288519..17936421680 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad3..f782db79879 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77f..563b8f07b2a 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ def adb_decorator( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity): entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e52..4482f50f3e2 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4e..e7d06a9f624 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py new file mode 100644 index 00000000000..fb275342cb0 --- /dev/null +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -0,0 +1,67 @@ +"""The Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, 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] + + +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]) + try: + await api.async_connect() + except InvalidAuth as exc: + # The Android TV is hard reset or the certificate and key files were deleted. + raise ConfigEntryAuthFailed from exc + except (CannotConnect, ConnectionClosed) as exc: + # The Android TV is network unreachable. Raise exception and let Home Assistant retry + # later. If device gets a new IP address the zeroconf flow will update the config. + raise ConfigEntryNotReady from exc + + def reauth_needed() -> None: + """Start a reauth flow if Android TV is hard reset while reconnecting.""" + entry.async_start_reauth(hass) + + # Start a task (canceled in disconnect) to keep reconnecting if device becomes + # network unreachable. If device gets a new IP address the zeroconf flow will + # update the config entry data and reload the config entry. + api.keep_reconnecting(reauth_needed) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + @callback + def on_hass_stop(event) -> None: + """Stop push updates when hass stops.""" + api.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + 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): + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + api.disconnect() + + return unload_ok diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py new file mode 100644 index 00000000000..24b64c622a9 --- /dev/null +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for Android TV Remote integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from androidtvremote2 import ( + AndroidTVRemote, + CannotConnect, + ConnectionClosed, + InvalidAuth, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .helpers import create_api + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required("host"): str, + } +) + +STEP_PAIR_DATA_SCHEMA = vol.Schema( + { + vol.Required("pin"): str, + } +) + + +class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Android TV Remote.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new AndroidTVRemoteConfigFlow.""" + self.api: AndroidTVRemote | None = None + self.reauth_entry: config_entries.ConfigEntry | None = None + self.host: str | None = None + self.name: str | None = None + self.mac: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self.host = user_input["host"] + assert self.host + api = create_api(self.hass, self.host) + try: + self.name, self.mac = await api.async_get_name_and_mac() + assert self.mac + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Likely invalid IP address or device is network unreachable. Stay + # in the user step allowing the user to enter a different host. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def _async_start_pair(self) -> FlowResult: + """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen.""" + assert self.host + self.api = create_api(self.hass, self.host) + await self.api.async_generate_cert_if_missing() + await self.api.async_start_pairing() + return await self.async_step_pair() + + async def async_step_pair( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the pair step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + pin = user_input["pin"] + assert self.api + await self.api.async_finish_pairing(pin) + if self.reauth_entry: + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + assert self.name + return self.async_create_entry( + title=self.name, + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) + except InvalidAuth: + # Invalid PIN. Stay in the pair step allowing the user to enter + # a different PIN. + errors["base"] = "invalid_auth" + except ConnectionClosed: + # Either user canceled pairing on the Android TV itself (most common) + # or device doesn't respond to the specified host (device was unplugged, + # network was unplugged, or device got a new IP address). + # Attempt to pair again. + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device doesn't respond to the specified host. Abort. + # If we are in the user flow we could go back to the user step to allow + # them to enter a new IP address but we cannot do that for the zeroconf + # flow. Simpler to abort for both flows. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="pair", + data_schema=STEP_PAIR_DATA_SCHEMA, + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.host = discovery_info.host + self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") + self.mac = discovery_info.properties.get("bt") + assert self.mac + 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} + ) + self.context.update({"title_placeholders": {CONF_NAME: self.name}}) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device became network unreachable after discovery. + # Abort and let discovery find it again later. + return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.name}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.host = entry_data[CONF_HOST] + self.name = entry_data[CONF_NAME] + self.mac = entry_data[CONF_MAC] + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + return await self._async_start_pair() + except (CannotConnect, ConnectionClosed): + # Device is network unreachable. Abort. + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self.name}, + errors=errors, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py new file mode 100644 index 00000000000..82f494b81aa --- /dev/null +++ b/homeassistant/components/androidtv_remote/const.py @@ -0,0 +1,6 @@ +"""Constants for the Android TV Remote integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "androidtv_remote" diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py new file mode 100644 index 00000000000..28d16bf94fe --- /dev/null +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Android TV Remote.""" +from __future__ import annotations + +from typing import Any + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_HOST, CONF_MAC} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN].pop(entry.entry_id) + return async_redact_data( + { + "api_device_info": api.device_info, + "config_entry_data": entry.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py new file mode 100644 index 00000000000..0bc1f1b904f --- /dev/null +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -0,0 +1,18 @@ +"""Helper functions for Android TV Remote integration.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + + +def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + """Create an AndroidTVRemote instance.""" + return AndroidTVRemote( + client_name="Home Assistant", + certfile=hass.config.path(STORAGE_DIR, "androidtv_remote_cert.pem"), + keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"), + host=host, + loop=hass.loop, + ) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json new file mode 100644 index 00000000000..0e5d896a11f --- /dev/null +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "androidtv_remote", + "name": "Android TV Remote", + "codeowners": ["@tronikos"], + "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"], + "zeroconf": ["_androidtvremote2._tcp.local."] +} diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py new file mode 100644 index 00000000000..1c68c92bc68 --- /dev/null +++ b/homeassistant/components/androidtv_remote/remote.py @@ -0,0 +1,154 @@ +"""Remote control support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, + 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 + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV remote entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) + + +class AndroidTVRemoteEntity(RemoteEntity): + """Representation of an Android TV Remote.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + 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 is_on_updated(is_on: bool) -> None: + self._attr_is_on = is_on + self.async_write_ha_state() + + @callback + def current_app_updated(current_app: str) -> None: + self._attr_current_activity = current_app + self.async_write_ha_state() + + @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() + + 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) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the Android TV on.""" + if not self.is_on: + self._send_key_command("POWER") + activity = kwargs.get(ATTR_ACTIVITY, "") + if activity: + self._send_launch_app_command(activity) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the Android TV off.""" + if self.is_on: + self._send_key_command("POWER") + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to one device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) + + for _ in range(num_repeats): + for single_command in command: + if hold_secs: + self._send_key_command(single_command, "START_LONG") + await asyncio.sleep(hold_secs) + self._send_key_command(single_command, "END_LONG") + 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/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json new file mode 100644 index 00000000000..983c604370b --- /dev/null +++ b/homeassistant/components/androidtv_remote/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "zeroconf_confirm": { + "title": "Discovered Android TV", + "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, + "pair": { + "description": "Enter the pairing code displayed on the Android TV ({name}).", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to pair again with the Android TV ({name})." + } + }, + "error": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py new file mode 100644 index 00000000000..7810e00ded0 --- /dev/null +++ b/homeassistant/components/anova/__init__.py @@ -0,0 +1,86 @@ +"""The Anova integration.""" +from __future__ import annotations + +import logging + +from anova_wifi import ( + AnovaApi, + AnovaPrecisionCooker, + AnovaPrecisionCookerSensor, + InvalidLogin, + NoDevicesFound, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AnovaCoordinator +from .models import AnovaData +from .util import serialize_device_list + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Anova from a config entry.""" + api = AnovaApi( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + try: + await api.authenticate() + except InvalidLogin as err: + _LOGGER.error( + "Login was incorrect - please log back in through the config flow. %s", err + ) + return False + assert api.jwt + api.existing_devices = [ + AnovaPrecisionCooker( + aiohttp_client.async_get_clientsession(hass), + device[0], + device[1], + api.jwt, + ) + for device in entry.data["devices"] + ] + try: + new_devices = await api.get_devices() + except NoDevicesFound: + # get_devices raises an exception if no devices are online + new_devices = [] + devices = api.existing_devices + if new_devices: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + **{"devices": serialize_device_list(devices)}, + }, + ) + 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 + ] + coordinator.async_setup(str(firmware_version)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( + api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators + ) + 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/anova/config_flow.py b/homeassistant/components/anova/config_flow.py new file mode 100644 index 00000000000..5d0d2dbf628 --- /dev/null +++ b/homeassistant/components/anova/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Anova.""" +from __future__ import annotations + +from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .util import serialize_device_list + + +class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Sets up a config flow for Anova.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + api = AnovaApi( + aiohttp_client.async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + try: + await api.authenticate() + devices = await api.get_devices() + except InvalidLogin: + errors["base"] = "invalid_auth" + except NoDevicesFound: + errors["base"] = "no_devices_found" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + # We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline. + device_list = serialize_device_list(devices) + return self.async_create_entry( + title="Anova", + data={ + CONF_USERNAME: api.username, + CONF_PASSWORD: api.password, + "devices": device_list, + }, + ) + + 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/anova/const.py b/homeassistant/components/anova/const.py new file mode 100644 index 00000000000..0e3de12aca6 --- /dev/null +++ b/homeassistant/components/anova/const.py @@ -0,0 +1,6 @@ +"""Constants for the Anova integration.""" + +DOMAIN = "anova" + +ANOVA_CLIENT = "anova_api_client" +ANOVA_FIRMWARE_VERSION = "anova_firmware_version" diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py new file mode 100644 index 00000000000..cd4eab9c2e5 --- /dev/null +++ b/homeassistant/components/anova/coordinator.py @@ -0,0 +1,55 @@ +"""Support for Anova Coordinators.""" +from datetime import timedelta +import logging + +from anova_wifi import AnovaOffline, AnovaPrecisionCooker +import async_timeout + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AnovaCoordinator(DataUpdateCoordinator): + """Anova custom coordinator.""" + + data: dict[str, dict[str, str | int | float]] + + def __init__( + self, + hass: HomeAssistant, + anova_device: AnovaPrecisionCooker, + ) -> None: + """Set up Anova Coordinator.""" + super().__init__( + hass, + name="Anova Precision Cooker", + logger=_LOGGER, + update_interval=timedelta(seconds=30), + ) + assert self.config_entry is not None + self._device_unique_id = anova_device.device_key + self.anova_device = anova_device + self.device_info: DeviceInfo | None = None + + @callback + def async_setup(self, firmware_version: str) -> None: + """Set the firmware version info.""" + self.device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_unique_id)}, + name="Anova Precision Cooker", + manufacturer="Anova", + model="Precision Cooker", + sw_version=firmware_version, + ) + + async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]: + try: + async with async_timeout.timeout(5): + return await self.anova_device.update() + except AnovaOffline as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py new file mode 100644 index 00000000000..fd104e194f1 --- /dev/null +++ b/homeassistant/components/anova/entity.py @@ -0,0 +1,30 @@ +"""Base entity for the Anova integration.""" +from __future__ import annotations + +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import AnovaCoordinator + + +class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): + """Defines a Anova entity.""" + + def __init__(self, coordinator: AnovaCoordinator) -> None: + """Initialize the Anova entity.""" + super().__init__(coordinator) + self.device = coordinator.anova_device + self._attr_device_info = coordinator.device_info + self._attr_has_entity_name = True + + +class AnovaDescriptionEntity(AnovaEntity, Entity): + """Defines a Anova entity that uses a description.""" + + def __init__( + self, coordinator: AnovaCoordinator, description: EntityDescription + ) -> None: + """Initialize the entity and declare unique id based on description key.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator._device_unique_id}_{description.key}" diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json new file mode 100644 index 00000000000..d307a9314f9 --- /dev/null +++ b/homeassistant/components/anova/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anova", + "name": "Anova", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/anova", + "iot_class": "cloud_polling", + "loggers": ["anova_wifi"], + "requirements": ["anova-wifi==0.8.0"] +} diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py new file mode 100644 index 00000000000..a63355b2bbd --- /dev/null +++ b/homeassistant/components/anova/models.py @@ -0,0 +1,15 @@ +"""Dataclass models for the Anova integration.""" +from dataclasses import dataclass + +from anova_wifi import AnovaPrecisionCooker + +from .coordinator import AnovaCoordinator + + +@dataclass +class AnovaData: + """Data for the Anova integration.""" + + api_jwt: str + precision_cookers: list[AnovaPrecisionCooker] + coordinators: list[AnovaCoordinator] diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py new file mode 100644 index 00000000000..a5ea3ee2fd8 --- /dev/null +++ b/homeassistant/components/anova/sensor.py @@ -0,0 +1,97 @@ +"""Support for Anova Sensors.""" +from __future__ import annotations + +from anova_wifi import AnovaPrecisionCookerSensor + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .entity import AnovaDescriptionEntity +from .models import AnovaData + +SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.STATE, translation_key="state" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.MODE, translation_key="mode" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="target_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time_remaining", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="heater_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="triac_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="water_temperature", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Anova device.""" + anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AnovaSensor(coordinator, description) + for coordinator in anova_data.coordinators + for description in SENSOR_DESCRIPTIONS + ) + + +class AnovaSensor(AnovaDescriptionEntity, SensorEntity): + """A sensor using Anova coordinator.""" + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.coordinator.data["sensors"][self.entity_description.key] diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json new file mode 100644 index 00000000000..19d0e52b7d2 --- /dev/null +++ b/homeassistant/components/anova/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "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" + } + }, + "entity": { + "sensor": { + "cook_time": { + "name": "Cook time" + }, + "state": { + "name": "State" + }, + "mode": { + "name": "Mode" + }, + "target_temperature": { + "name": "Target temperature" + }, + "cook_time_remaining": { + "name": "Cook time remaining" + }, + "heater_temperature": { + "name": "Heater temperature" + }, + "triac_temperature": { + "name": "Triac temperature" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/components/anova/util.py b/homeassistant/components/anova/util.py new file mode 100644 index 00000000000..10e8fa0fef9 --- /dev/null +++ b/homeassistant/components/anova/util.py @@ -0,0 +1,8 @@ +"""Anova utilities.""" + +from anova_wifi import AnovaPrecisionCooker + + +def serialize_device_list(devices: list[AnovaPrecisionCooker]) -> list[tuple[str, str]]: + """Turn the device list into a serializable list that can be reconstructed.""" + return [(device.device_key, device.type) for device in devices] diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 1ca53c0e854..aef33a6f8bf 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_status": "No status is reported from [%key:common::config_flow::data::host%]" + "no_status": "No status is reported from host" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index d000c0346af..9b80d992cdd 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): all_identifiers = set(self.atv.all_identifiers) discovered_ip_address = str(self.atv.address) for entry in self._async_current_entries(): - if not all_identifiers.intersection( + existing_identifiers = set( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) - ): + ) + if not all_identifiers.intersection(existing_identifiers): continue - if entry.data.get(CONF_ADDRESS) != discovered_ip_address: + combined_identifiers = existing_identifiers | all_identifiers + if entry.data.get( + CONF_ADDRESS + ) != discovered_ip_address or combined_identifiers != set( + entry.data.get(CONF_IDENTIFIERS, []) + ): self.hass.config_entries.async_update_entry( entry, - data={**entry.data, CONF_ADDRESS: discovered_ip_address}, - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + data={ + **entry.data, + CONF_ADDRESS: discovered_ip_address, + CONF_IDENTIFIERS: list(combined_identifiers), + }, ) + if entry.source != config_entries.SOURCE_IGNORE: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) if not allow_exist: raise DeviceAlreadyConfigured() diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 33521b3d066..f1471f29666 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -75,7 +75,7 @@ class AuthorizationServer: token_url: str -class ApplicationCredentialsStorageCollection(collection.StorageCollection): +class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): """Application credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection): return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" async def _update_data( - self, data: dict[str, str], update_data: dict[str, str] + self, item: dict[str, str], update_data: dict[str, str] ) -> dict[str, str]: """Return a new updated data object.""" raise ValueError("Updates not supported") @@ -144,13 +144,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() hass.data[DOMAIN][DATA_STORAGE] = storage_collection - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 132e6cedda0..9a76d4843f0 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.2.1"], + "requirements": ["arcam-fmj==1.3.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py new file mode 100644 index 00000000000..7af379804e1 --- /dev/null +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -0,0 +1,80 @@ +"""The Assist pipeline integration.""" +from __future__ import annotations + +from collections.abc import AsyncIterable + +from homeassistant.components import stt +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .error import PipelineNotFound +from .pipeline import ( + Pipeline, + PipelineEvent, + PipelineEventCallback, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_create_default_pipeline, + async_get_pipeline, + async_get_pipelines, + async_setup_pipeline_store, +) +from .websocket_api import async_register_websocket_api + +__all__ = ( + "DOMAIN", + "async_create_default_pipeline", + "async_get_pipelines", + "async_setup", + "async_pipeline_from_audio_stream", + "Pipeline", + "PipelineEvent", + "PipelineEventType", +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Assist pipeline integration.""" + await async_setup_pipeline_store(hass) + async_register_websocket_api(hass) + + return True + + +async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + event_callback: PipelineEventCallback, + stt_metadata: stt.SpeechMetadata, + stt_stream: AsyncIterable[bytes], + pipeline_id: str | None = None, + 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" + ) + + pipeline_input = PipelineInput( + conversation_id=conversation_id, + stt_metadata=stt_metadata, + stt_stream=stt_stream, + run=PipelineRun( + hass, + context=context, + pipeline=pipeline, + 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/const.py b/homeassistant/components/assist_pipeline/const.py new file mode 100644 index 00000000000..5cbdd5d6350 --- /dev/null +++ b/homeassistant/components/assist_pipeline/const.py @@ -0,0 +1,2 @@ +"""Constants for the Assist pipeline integration.""" +DOMAIN = "assist_pipeline" diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py new file mode 100644 index 00000000000..fa26d916eeb --- /dev/null +++ b/homeassistant/components/assist_pipeline/error.py @@ -0,0 +1,30 @@ +"""Assist pipeline errors.""" + +from homeassistant.exceptions import HomeAssistantError + + +class PipelineError(HomeAssistantError): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class PipelineNotFound(PipelineError): + """Unspecified pipeline picked.""" + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json new file mode 100644 index 00000000000..e97ceae5dec --- /dev/null +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "assist_pipeline", + "name": "Assist pipeline", + "codeowners": ["@balloob", "@synesthesiam"], + "dependencies": ["conversation", "stt", "tts"], + "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", + "iot_class": "local_push", + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] +} diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py new file mode 100644 index 00000000000..d347e433f46 --- /dev/null +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -0,0 +1,974 @@ +"""Classes for voice assistant pipelines.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable, Iterable +from dataclasses import asdict, dataclass, field +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.components import conversation, media_source, stt, tts, websocket_api +from homeassistant.components.tts.media_source import ( + generate_media_source_id as tts_generate_media_source_id, +) +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.collection import ( + CollectionError, + ItemNotFound, + SerializedStorageCollection, + StorageCollection, + StorageCollectionWebsocket, +) +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.util import ( + dt as dt_util, + language as language_util, + ulid as ulid_util, +) +from homeassistant.util.limited_size_dict import LimitedSizeDict + +from .const import DOMAIN +from .error import ( + IntentRecognitionError, + PipelineError, + SpeechToTextError, + TextToSpeechError, +) + +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY = f"{DOMAIN}.pipelines" +STORAGE_VERSION = 1 + +ENGINE_LANGUAGE_PAIRS = ( + ("stt_engine", "stt_language"), + ("tts_engine", "tts_language"), +) + + +def validate_language(data: dict[str, Any]) -> Any: + """Validate language settings.""" + for engine, language in ENGINE_LANGUAGE_PAIRS: + if data[engine] is not None and data[language] is None: + raise vol.Invalid(f"Need language {language} for {engine} {data[engine]}") + return data + + +PIPELINE_FIELDS = { + vol.Required("conversation_engine"): str, + vol.Required("conversation_language"): str, + vol.Required("language"): str, + vol.Required("name"): str, + vol.Required("stt_engine"): vol.Any(str, None), + vol.Required("stt_language"): vol.Any(str, None), + vol.Required("tts_engine"): vol.Any(str, None), + vol.Required("tts_language"): vol.Any(str, None), + vol.Required("tts_voice"): vol.Any(str, None), +} + +STORED_PIPELINE_RUNS = 10 + +SAVE_DELAY = 10 + + +async def _async_resolve_default_pipeline_settings( + hass: HomeAssistant, + stt_engine_id: str | None, + tts_engine_id: str | None, +) -> dict[str, str | None]: + """Resolve settings for a default pipeline. + + The default pipeline will use the homeassistant conversation agent and the + default stt / tts engines if none are specified. + """ + conversation_language = "en" + pipeline_language = "en" + pipeline_name = "Home Assistant" + stt_engine = None + stt_language = None + tts_engine = None + tts_language = None + tts_voice = None + + # Find a matching language supported by the Home Assistant conversation agent + conversation_languages = language_util.matches( + hass.config.language, + await conversation.async_get_conversation_languages( + hass, conversation.HOME_ASSISTANT_AGENT + ), + country=hass.config.country, + ) + if conversation_languages: + pipeline_language = hass.config.language + conversation_language = conversation_languages[0] + + if stt_engine_id is None: + stt_engine_id = stt.async_default_engine(hass) + + if stt_engine_id is not None: + stt_engine = stt.async_get_speech_to_text_engine(hass, stt_engine_id) + if stt_engine is None: + stt_engine_id = None + + if stt_engine: + stt_languages = language_util.matches( + pipeline_language, + stt_engine.supported_languages, + country=hass.config.country, + ) + if stt_languages: + stt_language = stt_languages[0] + else: + _LOGGER.debug( + "Speech to text engine '%s' does not support language '%s'", + stt_engine_id, + pipeline_language, + ) + stt_engine_id = None + + if tts_engine_id is None: + tts_engine_id = tts.async_default_engine(hass) + + if tts_engine_id is not None: + tts_engine = tts.get_engine_instance(hass, tts_engine_id) + if tts_engine is None: + tts_engine_id = None + + if tts_engine: + tts_languages = language_util.matches( + pipeline_language, + tts_engine.supported_languages, + country=hass.config.country, + ) + if tts_languages: + tts_language = tts_languages[0] + tts_voices = tts_engine.async_get_supported_voices(tts_language) + if tts_voices: + tts_voice = tts_voices[0].voice_id + else: + _LOGGER.debug( + "Text to speech engine '%s' does not support language '%s'", + tts_engine_id, + pipeline_language, + ) + tts_engine_id = None + + if stt_engine_id == "cloud" and tts_engine_id == "cloud": + pipeline_name = "Home Assistant Cloud" + + return { + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, + "conversation_language": conversation_language, + "language": hass.config.language, + "name": pipeline_name, + "stt_engine": stt_engine_id, + "stt_language": stt_language, + "tts_engine": tts_engine_id, + "tts_language": tts_language, + "tts_voice": tts_voice, + } + + +async def _async_create_default_pipeline( + hass: HomeAssistant, pipeline_store: PipelineStorageCollection +) -> Pipeline: + """Create a default pipeline. + + The default pipeline will use the homeassistant conversation agent and the + default stt / tts engines. + """ + pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None) + return await pipeline_store.async_create_item(pipeline_settings) + + +async def async_create_default_pipeline( + hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str +) -> Pipeline | None: + """Create a pipeline with default settings. + + The default pipeline will use the homeassistant conversation agent and the + specified stt / tts engines. + """ + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + pipeline_settings = await _async_resolve_default_pipeline_settings( + hass, stt_engine_id, tts_engine_id + ) + if ( + pipeline_settings["stt_engine"] != stt_engine_id + or pipeline_settings["tts_engine"] != tts_engine_id + ): + return None + return await pipeline_store.async_create_item(pipeline_settings) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or the preferred pipeline.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + if pipeline_id is None: + # 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) + + +@callback +def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]: + """Get all pipelines.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + return pipeline_data.pipeline_store.data.values() + + +class PipelineEventType(StrEnum): + """Event types emitted during a pipeline run.""" + + RUN_START = "run-start" + RUN_END = "run-end" + STT_START = "stt-start" + STT_END = "stt-end" + INTENT_START = "intent-start" + INTENT_END = "intent-end" + TTS_START = "tts-start" + TTS_END = "tts-end" + ERROR = "error" + + +@dataclass(frozen=True) +class PipelineEvent: + """Events emitted during a pipeline run.""" + + type: PipelineEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) + + +PipelineEventCallback = Callable[[PipelineEvent], None] + + +@dataclass(frozen=True) +class Pipeline: + """A voice assistant pipeline.""" + + conversation_engine: str + conversation_language: str + language: str + name: str + stt_engine: str | None + stt_language: str | None + tts_engine: str | None + tts_language: str | None + tts_voice: str | None + + id: str = field(default_factory=ulid_util.ulid) + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "conversation_engine": self.conversation_engine, + "conversation_language": self.conversation_language, + "id": self.id, + "language": self.language, + "name": self.name, + "stt_engine": self.stt_engine, + "stt_language": self.stt_language, + "tts_engine": self.tts_engine, + "tts_language": self.tts_language, + "tts_voice": self.tts_voice, + } + + +class PipelineStage(StrEnum): + """Stages of a pipeline.""" + + STT = "stt" + INTENT = "intent" + TTS = "tts" + + +PIPELINE_STAGE_ORDER = [ + PipelineStage.STT, + PipelineStage.INTENT, + PipelineStage.TTS, +] + + +class PipelineRunValidationError(Exception): + """Error when a pipeline run is not valid.""" + + +class InvalidPipelineStagesError(PipelineRunValidationError): + """Error when given an invalid combination of start/end stages.""" + + def __init__( + self, + start_stage: PipelineStage, + end_stage: PipelineStage, + ) -> None: + """Set error message.""" + super().__init__( + f"Invalid stage combination: start={start_stage}, end={end_stage}" + ) + + +@dataclass +class PipelineRun: + """Running context for a pipeline.""" + + hass: HomeAssistant + context: Context + pipeline: Pipeline + start_stage: PipelineStage + end_stage: PipelineStage + 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) + tts_options: dict | None = field(init=False, default=None) + + def __post_init__(self) -> None: + """Set language for pipeline.""" + self.language = self.pipeline.language or self.hass.config.language + + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.pipeline.id not in pipeline_data.pipeline_runs: + pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + size_limit=STORED_PIPELINE_RUNS + ) + pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + + @callback + def process_event(self, event: PipelineEvent) -> None: + """Log an event and call listener.""" + self.event_callback(event) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + # This run has been evicted from the logged pipeline runs already + return + pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + + def start(self) -> None: + """Emit run start event.""" + data = { + "pipeline": self.pipeline.id, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) + + def end(self) -> None: + """Emit run end event.""" + self.process_event( + PipelineEvent( + PipelineEventType.RUN_END, + ) + ) + + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None + + # pipeline.stt_engine can't be None or this function is not called + stt_provider = stt.async_get_speech_to_text_engine( + self.hass, + self.pipeline.stt_engine, # type: ignore[arg-type] + ) + + if stt_provider is None: + engine = self.pipeline.stt_engine + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + metadata.language = self.pipeline.stt_language or self.language + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {stt_provider.name} does not support input speech " + f"to text metadata {metadata}" + ), + ) + + self.stt_provider = stt_provider + + async def speech_to_text( + self, + 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") + + if isinstance(self.stt_provider, stt.Provider): + engine = self.stt_provider.name + else: + engine = self.stt_provider.entity_id + + self.process_event( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Transcribe audio stream + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during speech to text") + raise SpeechToTextError( + code="stt-stream-failed", + message="Unexpected error during speech to text", + ) from src_error + + _LOGGER.debug("speech-to-text result %s", result) + + if result.result != stt.SpeechResultState.SUCCESS: + raise SpeechToTextError( + code="stt-stream-failed", + message="Speech to text failed", + ) + + if not result.text: + raise SpeechToTextError( + code="stt-no-text-recognized", message="No text recognized" + ) + + self.process_event( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, + # If no conversation engine is set, use the Home Assistant agent + # (the conversation integration default is currently the last one set) + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info.id + + async def recognize_intent( + self, intent_input: str, conversation_id: str | None + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + + self.process_event( + PipelineEvent( + PipelineEventType.INTENT_START, + { + "engine": self.intent_agent, + "language": self.pipeline.conversation_language, + "intent_input": intent_input, + }, + ) + ) + + try: + conversation_result = await conversation.async_converse( + hass=self.hass, + text=intent_input, + conversation_id=conversation_id, + context=self.context, + language=self.pipeline.conversation_language, + agent_id=self.intent_agent, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during intent recognition") + raise IntentRecognitionError( + code="intent-failed", + message="Unexpected error during intent recognition", + ) from src_error + + _LOGGER.debug("conversation result %s", conversation_result) + + self.process_event( + PipelineEvent( + PipelineEventType.INTENT_END, + {"intent_output": conversation_result.as_dict()}, + ) + ) + + speech: str = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + + return speech + + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = self.pipeline.tts_engine + + tts_options = {} + if self.pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice + + if self.tts_audio_output is not None: + tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output + + try: + # pipeline.tts_engine can't be None or this function is not called + if not await tts.async_support_options( + self.hass, + engine, # type: ignore[arg-type] + 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", + ) from err + + 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") + + self.process_event( + PipelineEvent( + PipelineEventType.TTS_START, + { + "engine": self.tts_engine, + "language": self.pipeline.tts_language, + "voice": self.pipeline.tts_voice, + "tts_input": tts_input, + }, + ) + ) + + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text to speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text to speech", + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) + + self.process_event( + PipelineEvent( + PipelineEventType.TTS_END, + { + "tts_output": { + "media_id": tts_media_id, + **asdict(tts_media), + } + }, + ) + ) + + return tts_media.url + + +@dataclass +class PipelineInput: + """Input to a pipeline run.""" + + run: PipelineRun + + stt_metadata: stt.SpeechMetadata | None = None + """Metadata of stt input audio. Required when start_stage = stt.""" + + stt_stream: AsyncIterable[bytes] | None = None + """Input audio for stt. Required when start_stage = stt.""" + + intent_input: str | None = None + """Input for conversation agent. Required when start_stage = intent.""" + + tts_input: str | None = None + """Input for text to speech. Required when start_stage = tts.""" + + conversation_id: str | None = None + + async def execute(self) -> None: + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage + + try: + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await self.run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT + + if self.run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await self.run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if self.run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await self.run.text_to_speech(tts_input) + + except PipelineError as err: + self.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return + + self.run.end() + + async def validate(self) -> None: + """Validate pipeline input against start stage.""" + 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" + ) + if self.stt_metadata is None: + raise PipelineRunValidationError( + "stt_metadata is required for speech to text" + ) + if self.stt_stream is None: + raise PipelineRunValidationError( + "stt_stream is required for speech to text" + ) + elif self.run.start_stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif self.run.start_stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "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" + ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + # self.stt_metadata can't be None or we'd raise above + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) + + +class PipelinePreferred(CollectionError): + """Raised when attempting to delete the preferred pipelen.""" + + def __init__(self, item_id: str) -> None: + """Initialize pipeline preferred error.""" + super().__init__(f"Item {item_id} preferred.") + self.item_id = item_id + + +class SerializedPipelineStorageCollection(SerializedStorageCollection): + """Serialized pipeline storage collection.""" + + preferred_item: str + + +class PipelineStorageCollection( + StorageCollection[Pipeline, SerializedPipelineStorageCollection] +): + """Pipeline storage collection.""" + + _preferred_item: str + + async def _async_load_data(self) -> SerializedPipelineStorageCollection | None: + """Load the data.""" + if not (data := await super()._async_load_data()): + pipeline = await _async_create_default_pipeline(self.hass, self) + self._preferred_item = pipeline.id + return data + + self._preferred_item = data["preferred_item"] + + return data + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + validated_data: dict = validate_language(data) + return validated_data + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return ulid_util.ulid() + + async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: + """Return a new updated item.""" + update_data = validate_language(update_data) + return Pipeline(id=item.id, **update_data) + + def _create_item(self, item_id: str, data: dict) -> Pipeline: + """Create an item from validated config.""" + return Pipeline(id=item_id, **data) + + def _deserialize_item(self, data: dict) -> Pipeline: + """Create an item from its serialized representation.""" + return Pipeline(**data) + + def _serialize_item(self, item_id: str, item: Pipeline) -> dict: + """Return the serialized representation of an item for storing.""" + return item.to_json() + + async def async_delete_item(self, item_id: str) -> None: + """Delete item.""" + if self._preferred_item == item_id: + raise PipelinePreferred(item_id) + await super().async_delete_item(item_id) + + @callback + def async_get_preferred_item(self) -> str: + """Get the id of the preferred item.""" + return self._preferred_item + + @callback + def async_set_preferred_item(self, item_id: str) -> None: + """Set the preferred pipeline.""" + if item_id not in self.data: + raise ItemNotFound(item_id) + self._preferred_item = item_id + self._async_schedule_save() + + @callback + def _data_to_save(self) -> SerializedPipelineStorageCollection: + """Return JSON-compatible date for storing to file.""" + base_data = super()._base_data_to_save() + return { + "items": base_data["items"], + "preferred_item": self._preferred_item, + } + + +class PipelineStorageCollectionWebsocket( + StorageCollectionWebsocket[PipelineStorageCollection] +): + """Class to expose storage collection management over websocket.""" + + @callback + def async_setup( + self, + hass: HomeAssistant, + *, + create_list: bool = True, + create_create: bool = True, + ) -> None: + """Set up the websocket commands.""" + super().async_setup(hass, create_list=create_list, create_create=create_create) + + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/get", + self.ws_get_item, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): f"{self.api_prefix}/get", + vol.Optional(self.item_id_key): str, + } + ), + ) + + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/set_preferred", + websocket_api.require_admin( + websocket_api.async_response(self.ws_set_preferred_item) + ), + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): f"{self.api_prefix}/set_preferred", + vol.Required(self.item_id_key): str, + } + ), + ) + + async def ws_delete_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Delete an item.""" + try: + await super().ws_delete_item(hass, connection, msg) + except PipelinePreferred as exc: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc) + ) + + @callback + def ws_get_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Get an item.""" + item_id = msg.get(self.item_id_key) + if item_id is None: + item_id = self.storage_collection.async_get_preferred_item() + + if item_id not in self.storage_collection.data: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"Unable to find {self.item_id_key} {item_id}", + ) + return + + connection.send_result(msg["id"], self.storage_collection.data[item_id]) + + @callback + def ws_list_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """List items.""" + connection.send_result( + msg["id"], + { + "pipelines": self.storage_collection.async_items(), + "preferred_pipeline": self.storage_collection.async_get_preferred_item(), + }, + ) + + async def ws_set_preferred_item( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Set the preferred item.""" + try: + self.storage_collection.async_set_preferred_item(msg[self.item_id_key]) + except ItemNotFound: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item" + ) + return + connection.send_result(msg["id"]) + + +@dataclass +class PipelineData: + """Store and debug data stored in hass.data.""" + + pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] + pipeline_store: PipelineStorageCollection + + +@dataclass +class PipelineRunDebug: + """Debug data for a pipelinerun.""" + + events: list[PipelineEvent] = field(default_factory=list, init=False) + timestamp: str = field( + default_factory=lambda: dt_util.utcnow().isoformat(), + init=False, + ) + + +@singleton(DOMAIN) +async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: + """Set up the pipeline storage collection.""" + pipeline_store = PipelineStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY) + ) + await pipeline_store.async_load() + PipelineStorageCollectionWebsocket( + pipeline_store, + f"{DOMAIN}/pipeline", + "pipeline", + PIPELINE_FIELDS, + PIPELINE_FIELDS, + ).async_setup(hass) + return PipelineData({}, pipeline_store) diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py new file mode 100644 index 00000000000..9ac1d6b5888 --- /dev/null +++ b/homeassistant/components/assist_pipeline/select.py @@ -0,0 +1,95 @@ +"""Select entities for a pipeline.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import collection, entity_registry as er, restore_state + +from .const import DOMAIN +from .pipeline import PipelineStorageCollection + +OPTION_PREFERRED = "preferred" + + +@callback +def get_chosen_pipeline( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> str | None: + """Get the chosen pipeline for a domain.""" + ent_reg = er.async_get(hass) + pipeline_entity_id = ent_reg.async_get_entity_id( + Platform.SELECT, domain, f"{unique_id_prefix}-pipeline" + ) + if pipeline_entity_id is None: + return None + + state = hass.states.get(pipeline_entity_id) + if state is None or state.state == OPTION_PREFERRED: + return None + + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store + return next( + (item.id for item in pipeline_store.async_items() if item.name == state.state), + None, + ) + + +class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): + """Entity to represent a pipeline selector.""" + + entity_description = SelectEntityDescription( + key="pipeline", + translation_key="pipeline", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = OPTION_PREFERRED + _attr_options = [OPTION_PREFERRED] + + def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + """Initialize a pipeline selector.""" + self._attr_unique_id = f"{unique_id_prefix}-pipeline" + self.hass = hass + self._update_options() + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + pipeline_store: PipelineStorageCollection = self.hass.data[ + DOMAIN + ].pipeline_store + pipeline_store.async_add_change_set_listener(self._pipelines_updated) + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + + async def _pipelines_updated( + self, change_sets: Iterable[collection.CollectionChangeSet] + ) -> None: + """Handle pipeline update.""" + self._update_options() + self.async_write_ha_state() + + @callback + def _update_options(self) -> None: + """Handle pipeline update.""" + pipeline_store: PipelineStorageCollection = self.hass.data[ + DOMAIN + ].pipeline_store + options = [OPTION_PREFERRED] + options.extend(sorted(item.name for item in pipeline_store.async_items())) + self._attr_options = options + + if self._attr_current_option not in options: + self._attr_current_option = OPTION_PREFERRED diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json new file mode 100644 index 00000000000..d85eb1aaed9 --- /dev/null +++ b/homeassistant/components/assist_pipeline/strings.json @@ -0,0 +1,17 @@ +{ + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "Assist in progress" + } + }, + "select": { + "pipeline": { + "name": "Assist pipeline", + "state": { + "preferred": "Preferred" + } + } + } + } +} diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py new file mode 100644 index 00000000000..c5f87f1336a --- /dev/null +++ b/homeassistant/components/assist_pipeline/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self) -> None: + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self) -> None: + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self.in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self.in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self.in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py new file mode 100644 index 00000000000..6c1dbe3dbce --- /dev/null +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -0,0 +1,333 @@ +"""Assist pipeline Websocket API.""" +import asyncio + +# Suppressing disable=deprecated-module is needed for Python 3.11 +import audioop # pylint: disable=deprecated-module +from collections.abc import AsyncGenerator, Callable +import logging +from typing import Any + +import async_timeout +import voluptuous as vol + +from homeassistant.components import conversation, stt, tts, websocket_api +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util import language as language_util + +from .const import DOMAIN +from .pipeline import ( + PipelineData, + PipelineError, + PipelineEvent, + PipelineEventType, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) +from .vad import VoiceCommandSegmenter + +DEFAULT_TIMEOUT = 30 + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_run) + websocket_api.async_register_command(hass, websocket_list_languages) + websocket_api.async_register_command(hass, websocket_list_runs) + websocket_api.async_register_command(hass, websocket_get_run) + + +@websocket_api.websocket_command( + vol.All( + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): "assist_pipeline/run", + # pylint: disable-next=unnecessary-lambda + vol.Required("start_stage"): lambda val: PipelineStage(val), + # pylint: disable-next=unnecessary-lambda + vol.Required("end_stage"): lambda val: PipelineStage(val), + vol.Optional("input"): dict, + vol.Optional("pipeline"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("timeout"): vol.Any(float, int), + }, + ), + cv.key_value_schemas( + "start_stage", + { + PipelineStage.STT: vol.Schema( + {vol.Required("input"): {vol.Required("sample_rate"): int}}, + extra=vol.ALLOW_EXTRA, + ), + PipelineStage.INTENT: vol.Schema( + {vol.Required("input"): {"text": str}}, + extra=vol.ALLOW_EXTRA, + ), + PipelineStage.TTS: vol.Schema( + {vol.Required("input"): {"text": str}}, + extra=vol.ALLOW_EXTRA, + ), + }, + ), + ), +) +@websocket_api.async_response +async def websocket_run( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Run a pipeline.""" + pipeline_id = msg.get("pipeline") + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}", + ) + return + + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + start_stage = PipelineStage(msg["start_stage"]) + end_stage = PipelineStage(msg["end_stage"]) + handler_id: int | None = None + unregister_handler: Callable[[], None] | None = None + + # Arguments to PipelineInput + input_args: dict[str, Any] = { + "conversation_id": msg.get("conversation_id"), + } + + if start_stage == PipelineStage.STT: + # Audio pipeline that will receive audio as binary websocket messages + audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + incoming_sample_rate = msg["input"]["sample_rate"] + + async def stt_stream() -> AsyncGenerator[bytes, None]: + state = None + segmenter = VoiceCommandSegmenter() + + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv( + chunk, 2, 1, incoming_sample_rate, 16000, state + ) + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + def handle_binary( + _hass: HomeAssistant, + _connection: websocket_api.ActiveConnection, + data: bytes, + ) -> None: + # Forward to STT audio stream + audio_queue.put_nowait(data) + + handler_id, unregister_handler = connection.async_register_binary_handler( + handle_binary + ) + + # Audio input must be raw PCM at 16Khz with 16-bit mono samples + input_args["stt_metadata"] = stt.SpeechMetadata( + language=pipeline.stt_language or pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + input_args["stt_stream"] = stt_stream() + elif start_stage == PipelineStage.INTENT: + # Input to conversation agent + input_args["intent_input"] = msg["input"]["text"] + elif start_stage == PipelineStage.TTS: + # Input to text to speech system + input_args["tts_input"] = msg["input"]["text"] + + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, + ) + + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return + + # Confirm subscription + connection.send_result(msg["id"]) + + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + + try: + # Task contains a timeout + async with async_timeout.timeout(timeout): + await run_task + except asyncio.TimeoutError: + pipeline_input.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ) + ) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/pipeline_debug/list", + vol.Required("pipeline_id"): str, + } +) +def websocket_list_runs( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List pipeline runs for which debug data is available.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = msg["pipeline_id"] + + if pipeline_id not in pipeline_data.pipeline_runs: + connection.send_result(msg["id"], {"pipeline_runs": []}) + return + + pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + + connection.send_result( + msg["id"], + { + "pipeline_runs": [ + {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp} + for id, pipeline_run in pipeline_runs.items() + ] + }, + ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/pipeline_debug/get", + vol.Required("pipeline_id"): str, + vol.Required("pipeline_run_id"): str, + } +) +def websocket_get_run( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get debug data for a pipeline run.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = msg["pipeline_id"] + pipeline_run_id = msg["pipeline_run_id"] + + if pipeline_id not in pipeline_data.pipeline_runs: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"pipeline_id {pipeline_id} not found", + ) + return + + pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + + if pipeline_run_id not in pipeline_runs: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"pipeline_run_id {pipeline_run_id} not found", + ) + return + + connection.send_result( + msg["id"], + {"events": pipeline_runs[pipeline_run_id].events}, + ) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/language/list", + } +) +@websocket_api.async_response +async def websocket_list_languages( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List languages which are supported by a complete pipeline. + + This will return a list of languages which are supported by at least one stt, tts + and conversation engine respectively. + """ + conv_language_tags = await conversation.async_get_conversation_languages(hass) + stt_language_tags = stt.async_get_speech_to_text_languages(hass) + tts_language_tags = tts.async_get_text_to_speech_languages(hass) + pipeline_languages: set[str] | None = None + + if conv_language_tags and conv_language_tags != MATCH_ALL: + languages = set() + for language_tag in conv_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + pipeline_languages = languages + + if stt_language_tags: + languages = set() + for language_tag in stt_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + if pipeline_languages is not None: + pipeline_languages &= languages + else: + pipeline_languages = languages + + if tts_language_tags: + languages = set() + for language_tag in tts_language_tags: + dialect = language_util.Dialect.parse(language_tag) + languages.add(dialect.language) + if pipeline_languages is not None: + pipeline_languages &= languages + else: + pipeline_languages = languages + + connection.send_result( + msg["id"], + {"languages": pipeline_languages}, + ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index bd0c706e74a..f6ccb5a7c9c 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -11,7 +11,7 @@ "password": "[%key:common::config_flow::data::password%]", "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", - "port": "[%key:common::config_flow::data::port%] (leave empty for protocol default)", + "port": "Port (leave empty for protocol default)", "mode": "[%key:common::config_flow::data::mode%]" } } diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index d49201f6d7b..cdf45db035c 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from atenpdu import AtenPE, AtenPEError +from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 687afdab4c7..ad9045a3d0d 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -3,6 +3,7 @@ import asyncio import logging from aiohttp import ClientError +from yalexs.util import get_latest_activity from homeassistant.core import callback from homeassistant.helpers.debounce import Debouncer @@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin): device_id = activity.device_id activity_type = activity.activity_type device_activities = self._latest_activities.setdefault(device_id, {}) - lastest_activity = device_activities.get(activity_type) - - # Ignore activities that are older than the latest one + # Ignore activities that are older than the latest one unless it is a non + # locking or unlocking activity with the exact same start time. if ( - lastest_activity - and lastest_activity.activity_start_time >= activity.activity_start_time + get_latest_activity(activity, device_activities.get(activity_type)) + != activity ): continue diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index d77a61a0659..b11550dccd7 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -5,7 +5,7 @@ from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus -from yalexs.util import update_lock_detail_from_activity +from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.config_entries import ConfigEntry @@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - lock_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, - {ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, + activity_stream = self._data.activity_stream + device_id = self._device_id + if lock_activity := activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION}, + ): + self._attr_changed_by = lock_activity.operated_by + + lock_activity_without_operator = activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, ) - if lock_activity is not None: - self._attr_changed_by = lock_activity.operated_by - update_lock_detail_from_activity(self._detail, lock_activity) - # If the source is pubnub the lock must be online since its a live update - if lock_activity.source == SOURCE_PUBNUB: + if latest_activity := get_latest_activity( + lock_activity_without_operator, lock_activity + ): + if latest_activity.source == SOURCE_PUBNUB: + # If the source is pubnub the lock must be online since its a live update self._detail.set_online(True) + update_lock_detail_from_activity(self._detail, latest_activity) bridge_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.BRIDGE_OPERATION} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 84b5ae7e205..4e5f8354a4c 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] + "requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.16"] } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3cfae999b92..4712592edc7 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -659,7 +659,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) -@dataclass +@dataclass(slots=True) class AutomationEntityConfig: """Container for prepared automation entity configuration.""" diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c515..65a425fa5c4 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index 4005460ecae..b318c5224df 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -4,8 +4,13 @@ from __future__ import annotations import json import logging +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus import ServiceBusMessage + +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.aio import ServiceBusClient, ServiceBusSender + +# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.exceptions import ( MessagingEntityNotFoundError, ServiceBusConnectionError, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd55..1f8b70f4d35 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,8 +23,10 @@ from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB -@dataclass + +@dataclass(slots=True) class Backup: """Backup class.""" @@ -99,7 +101,7 @@ class BackupManager: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ class BackupManager: self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ class BackupManager: with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5c..fb7e9eff780 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 360926363a5..a166c346f12 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -39,7 +39,11 @@ async def async_setup_entry( class BAFFan(BAFEntity, FanEntity): """BAF ceiling fan component.""" - _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.PRESET_MODE + ) _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index cf9a943b3df..b43b1fb6b7f 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -84,7 +84,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" hass = self.hass - ipaddress = host_port(discovery_info.__dict__) + ipaddress = (discovery_info.host, discovery_info.port) self.device_config["host"] = discovery_info.host self.device_config["port"] = discovery_info.port diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index add7dad1a1f..2c48b473b73 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -31,7 +31,7 @@ from homeassistant.config_entries import ( ConfigEntry, ) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback as hass_callback +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.debounce import Debouncer @@ -198,10 +198,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: function=_async_rediscover_adapters, ) + async def _async_shutdown_debouncer(_: Event) -> None: + """Shutdown debouncer.""" + await discovery_debouncer.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer) + async def _async_call_debouncer(now: datetime.datetime) -> None: """Call the debouncer at a later time.""" await discovery_debouncer.async_call() + call_debouncer_job = HassJob(_async_call_debouncer, cancel_on_shutdown=True) + def _async_trigger_discovery() -> None: # There are so many bluetooth adapter models that # we check the bus whenever a usb device is plugged in @@ -220,7 +228,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass, BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS + LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, - _async_call_debouncer, + call_debouncer_job, ) cancel = usb.async_register_scan_request_callback(hass, _async_trigger_discovery) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 6d4e67119d5..5fa05b87cc8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -169,3 +169,9 @@ class ActiveBluetoothDataUpdateCoordinator( # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): self.hass.async_create_task(self._debounced_poll.async_call()) + + @callback + def _async_stop(self) -> None: + """Cancel debouncer and stop the callbacks.""" + self._debounced_poll.async_cancel() + super()._async_stop() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index b450c612250..8e38191c820 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -158,3 +158,9 @@ class ActiveBluetoothProcessorCoordinator( # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): self.hass.async_create_task(self._debounced_poll.async_call()) + + @callback + def _async_stop(self) -> None: + """Cancel debouncer and stop the callbacks.""" + self._debounced_poll.async_cancel() + super()._async_stop() diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index d5e2ca0edbb..8f7750fe322 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -39,7 +39,7 @@ MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class BluetoothScannerDevice: """Data for a bluetooth device from a given scanner.""" @@ -309,43 +309,28 @@ class BaseHaRemoteScanner(BaseHaScanner): # merges the dicts on PropertiesChanged prev_device = prev_discovery[0] prev_advertisement = prev_discovery[1] - if ( - local_name - and prev_device.name - and len(prev_device.name) > len(local_name) - ): - local_name = prev_device.name - if service_uuids and service_uuids != prev_advertisement.service_uuids: - service_uuids = list( - set(service_uuids + prev_advertisement.service_uuids) - ) - elif not service_uuids: - service_uuids = prev_advertisement.service_uuids - if service_data and service_data != prev_advertisement.service_data: - service_data = {**prev_advertisement.service_data, **service_data} - elif not service_data: - service_data = prev_advertisement.service_data - if ( - manufacturer_data - and manufacturer_data != prev_advertisement.manufacturer_data - ): - manufacturer_data = { - **prev_advertisement.manufacturer_data, - **manufacturer_data, - } - elif not manufacturer_data: - manufacturer_data = prev_advertisement.manufacturer_data + prev_service_uuids = prev_advertisement.service_uuids + prev_service_data = prev_advertisement.service_data + prev_manufacturer_data = prev_advertisement.manufacturer_data + prev_name = prev_device.name - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - rssi=rssi, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - platform_data=(), - ) - if prev_discovery: + if local_name and prev_name and len(prev_name) > len(local_name): + local_name = prev_name + + if service_uuids and service_uuids != prev_service_uuids: + service_uuids = list(set(service_uuids + prev_service_uuids)) + elif not service_uuids: + service_uuids = prev_service_uuids + + if service_data and service_data != prev_service_data: + service_data = prev_service_data | service_data + elif not service_data: + service_data = prev_service_data + + if manufacturer_data and manufacturer_data != prev_manufacturer_data: + manufacturer_data = prev_manufacturer_data | manufacturer_data + elif not manufacturer_data: + manufacturer_data = prev_manufacturer_data # # Bleak updates the BLEDevice via create_or_update_device. # We need to do the same to ensure integrations that already @@ -366,6 +351,16 @@ class BaseHaRemoteScanner(BaseHaScanner): details=self._details | details, rssi=rssi, # deprecated, will be removed in newer bleak ) + + advertisement_data = AdvertisementData( + local_name=None if local_name == "" else local_name, + manufacturer_data=manufacturer_data, + service_data=service_data, + service_uuids=service_uuids, + tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, + rssi=rssi, + platform_data=(), + ) self._discovered_device_advertisement_datas[address] = ( device, advertisement_data, @@ -373,12 +368,12 @@ class BaseHaRemoteScanner(BaseHaScanner): self._discovered_device_timestamps[address] = now self._new_info_callback( BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, + name=local_name or address, + address=address, rssi=rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, + manufacturer_data=manufacturer_data, + service_data=service_data, + service_uuids=service_uuids, source=self.source, device=device, advertisement=advertisement_data, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 31b9bdb5d5e..84a86754ce6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.1", + "bleak==0.20.2", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", - "bluetooth-auto-recovery==1.0.3", - "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.2" + "bluetooth-auto-recovery==1.1.1", + "bluetooth-data-tools==0.4.0", + "dbus-fast==1.85.0" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index a7308bfd7ff..1315d0a834a 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -61,7 +61,7 @@ class BluetoothCallbackMatcherWithCallback( """Callback matcher for the bluetooth integration that stores the callback.""" -@dataclass(frozen=False) +@dataclass(slots=True, frozen=False) class IntegrationMatchHistory: """Track which fields have been seen.""" diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 40ac86de607..1856ccd5994 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -20,7 +20,7 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass +@dataclass(slots=True) class HaBluetoothConnector: """Data for how to connect a BLEDevice from a given scanner.""" diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index e1701487409..607abaa0168 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class PassiveBluetoothEntityKey: """Key for a passive bluetooth entity. @@ -36,7 +36,7 @@ class PassiveBluetoothEntityKey: _T = TypeVar("_T") -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class PassiveBluetoothDataUpdate(Generic[_T]): """Generic bluetooth data.""" diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index cf17796105b..67e401cd40a 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from .manager import BluetoothManager -@dataclass +@dataclass(slots=True) class _HaWrappedBleakBackend: """Wrap bleak backend to make it usable by Home Assistant.""" @@ -251,8 +251,10 @@ class HaBleakClientWrapper(BleakClient): assert models.MANAGER is not None manager = models.MANAGER wrapped_backend = self._async_get_best_available_backend_and_device(manager) + device = wrapped_backend.device + scanner = wrapped_backend.scanner self._backend = wrapped_backend.client( - wrapped_backend.device, + device, disconnected_callback=self._make_disconnected_callback( self.__disconnected_callback ), @@ -261,8 +263,9 @@ class HaBleakClientWrapper(BleakClient): ) if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): # Only lookup the description if we are going to log it - description = ble_device_description(wrapped_backend.device) - rssi = wrapped_backend.device.rssi + description = ble_device_description(device) + _, adv = scanner.discovered_devices_and_advertisement_data[device.address] + rssi = adv.rssi _LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi) connected = None try: @@ -271,11 +274,11 @@ class HaBleakClientWrapper(BleakClient): # If we failed to connect and its a local adapter (no source) # we release the connection slot if not connected: - self.__connect_failures[wrapped_backend.scanner] = ( - self.__connect_failures.get(wrapped_backend.scanner, 0) + 1 + self.__connect_failures[scanner] = ( + self.__connect_failures.get(scanner, 0) + 1 ) if not wrapped_backend.source: - manager.async_release_connection_slot(wrapped_backend.device) + manager.async_release_connection_slot(device) if debug_logging: _LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed591..8d5d842e915 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,8 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index f1768d5a0c7..3c7d2ba27c3 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.0"] + "requirements": ["bimmer_connected==0.13.2"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py new file mode 100644 index 00000000000..f26a2027f72 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -0,0 +1,120 @@ +"""Number 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 homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +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], float | int | None] + remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): + """Describes BMW number entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + mode: NumberMode = NumberMode.AUTO + + +NUMBER_TYPES: list[BMWNumberEntityDescription] = [ + BMWNumberEntityDescription( + key="target_soc", + name="Target SoC", + device_class=NumberDeviceClass.BATTERY, + is_available=lambda v: v.is_remote_set_target_soc_enabled, + native_max_value=100.0, + native_min_value=20.0, + native_step=5.0, + mode=NumberMode.SLIDER, + value_fn=lambda v: v.fuel_and_battery.charging_target, + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW number from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWNumber] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWNumber(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWNumber(BMWBaseEntity, NumberEntity): + """Representation of BMW Number entity.""" + + entity_description: BMWNumberEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWNumberEntityDescription, + ) -> None: + """Initialize an BMW Number.""" + 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: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_set_native_value(self, value: float) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + value, + ) + try: + await self.entity_description.remote_service(self.vehicle, value) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 00000000000..52d35b477a2 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,126 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +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.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 4e3218ba041..36af3974482 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,9 +17,9 @@ from homeassistant.const import ( ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .utils import BondDevice, BondHub @@ -27,6 +27,7 @@ from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) _FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) +_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60) class BondEntity(Entity): @@ -65,6 +66,7 @@ class BondEntity(Entity): self._attr_name = device.name self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() + self._bpup_polling_fallback: CALLBACK_TYPE | None = None @property def device_info(self) -> DeviceInfo: @@ -100,12 +102,13 @@ class BondEntity(Entity): return device_info async def async_update(self) -> None: - """Fetch assumed state of the cover from the hub using API.""" + """Perform a manual update from API.""" await self._async_update_from_api() @callback def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" + self._async_schedule_bpup_alive_or_poll() if ( self.hass.is_stopping or self._bpup_subs.alive @@ -172,16 +175,22 @@ class BondEntity(Entity): """Subscribe to BPUP and start polling.""" await super().async_added_to_hass() self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) - self.async_on_remove( - async_track_time_interval( - self.hass, - self._async_update_if_bpup_not_alive, - _FALLBACK_SCAN_INTERVAL, - name=f"Bond {self.entity_id} fallback polling", - ) + self._async_schedule_bpup_alive_or_poll() + + @callback + def _async_schedule_bpup_alive_or_poll(self) -> None: + """Schedule the BPUP alive or poll.""" + alive = self._bpup_subs.alive + self._bpup_polling_fallback = async_call_later( + self.hass, + _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, + self._async_update_if_bpup_not_alive, ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from BPUP data on remove.""" await super().async_will_remove_from_hass() self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback) + if self._bpup_polling_fallback: + self._bpup_polling_fallback() + self._bpup_polling_fallback = None diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index a856af83bb8..1512cf7b2b4 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -89,7 +89,8 @@ class BondFan(BondEntity, FanEntity): features |= FanEntityFeature.SET_SPEED if self._device.supports_direction(): features |= FanEntityFeature.DIRECTION - + if self._device.has_action(Action.BREEZE_ON): + features |= FanEntityFeature.PRESET_MODE return features @property diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index c5b42e73bee..5a0a9def0ae 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.2"], + "requirements": ["pybravia==0.3.3"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 5d996c2ee1f..559aae25abf 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -16,7 +16,7 @@ from .heartbeat import BroadlinkHeartbeat class BroadlinkData: """Class for sharing data within the Broadlink integration.""" - devices: dict = field(default_factory=dict) + devices: dict[str, BroadlinkDevice] = field(default_factory=dict) platforms: dict = field(default_factory=dict) heartbeat: BroadlinkHeartbeat | None = None @@ -29,7 +29,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Broadlink device from a config entry.""" - data = hass.data[DOMAIN] + data: BroadlinkData = hass.data[DOMAIN] if data.heartbeat is None: data.heartbeat = BroadlinkHeartbeat(hass) @@ -41,12 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - data = hass.data[DOMAIN] + data: BroadlinkData = hass.data[DOMAIN] device = data.devices.pop(entry.entry_id) result = await device.async_unload() - if not data.devices: + if data.heartbeat and not data.devices: await data.heartbeat.async_unload() data.heartbeat = None diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index d6d064ea011..87d8cf398fb 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -13,8 +13,15 @@ from broadlink.exceptions import ( ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_TIMEOUT, + CONF_TYPE, + Platform, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -24,7 +31,7 @@ from .updater import get_update_manager _LOGGER = logging.getLogger(__name__) -def get_domains(device_type): +def get_domains(device_type: str) -> set[Platform]: """Return the domains available for a device type.""" return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} @@ -32,33 +39,34 @@ def get_domains(device_type): class BroadlinkDevice: """Manages a Broadlink device.""" - def __init__(self, hass, config): + api: blk.Device + + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: """Initialize the device.""" self.hass = hass self.config = config - self.api = None self.update_manager = None - self.fw_version = None - self.authorized = None - self.reset_jobs = [] + self.fw_version: int | None = None + self.authorized: bool | None = None + self.reset_jobs: list[CALLBACK_TYPE] = [] @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self.config.title @property - def unique_id(self): + def unique_id(self) -> str | None: """Return the unique id of the device.""" return self.config.unique_id @property - def mac_address(self): + def mac_address(self) -> str: """Return the mac address of the device.""" return self.config.data[CONF_MAC] @property - def available(self): + def available(self) -> bool | None: """Return True if the device is available.""" if self.update_manager is None: return False @@ -77,14 +85,14 @@ class BroadlinkDevice: device_registry.async_update_device(device_entry.id, name=entry.title) await hass.config_entries.async_reload(entry.entry_id) - def _get_firmware_version(self): + def _get_firmware_version(self) -> int | None: """Get firmware version.""" self.api.auth() with suppress(BroadlinkException, OSError): return self.api.get_fwversion() return None - async def async_setup(self): + async def async_setup(self) -> bool: """Set up the device and related entities.""" config = self.config @@ -132,7 +140,7 @@ class BroadlinkDevice: return True - async def async_unload(self): + async def async_unload(self) -> bool: """Unload the device and related entities.""" if self.update_manager is None: return True @@ -144,7 +152,7 @@ class BroadlinkDevice: self.config, get_domains(self.api.type) ) - async def async_auth(self): + async def async_auth(self) -> bool: """Authenticate to the device.""" try: await self.hass.async_add_executor_job(self.api.auth) @@ -167,7 +175,7 @@ class BroadlinkDevice: raise return await self.hass.async_add_executor_job(request) - async def _async_handle_auth_error(self): + async def _async_handle_auth_error(self) -> None: """Handle an authentication error.""" if self.authorized is False: return diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py index b4deffa5b81..70f6aec0d0f 100644 --- a/homeassistant/components/broadlink/heartbeat.py +++ b/homeassistant/components/broadlink/heartbeat.py @@ -5,6 +5,7 @@ import logging import broadlink as blk from homeassistant.const import CONF_HOST +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import event from .const import DOMAIN @@ -21,12 +22,12 @@ class BroadlinkHeartbeat: HEARTBEAT_INTERVAL = dt.timedelta(minutes=2) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the heartbeat.""" self._hass = hass - self._unsubscribe = None + self._unsubscribe: CALLBACK_TYPE | None = None - async def async_setup(self): + async def async_setup(self) -> None: """Set up the heartbeat.""" if self._unsubscribe is None: await self.async_heartbeat(dt.datetime.now()) @@ -34,21 +35,21 @@ class BroadlinkHeartbeat: self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL ) - async def async_unload(self): + async def async_unload(self) -> None: """Unload the heartbeat.""" if self._unsubscribe is not None: self._unsubscribe() self._unsubscribe = None - async def async_heartbeat(self, now): + async def async_heartbeat(self, _: dt.datetime) -> None: """Send packets to feed watchdog timers.""" hass = self._hass config_entries = hass.config_entries.async_entries(DOMAIN) - hosts = {entry.data[CONF_HOST] for entry in config_entries} + hosts: set[str] = {entry.data[CONF_HOST] for entry in config_entries} await hass.async_add_executor_job(self.heartbeat, hosts) @staticmethod - def heartbeat(hosts): + def heartbeat(hosts: set[str]) -> None: """Send packets to feed watchdog timers.""" for host in hosts: try: diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py index d519909b290..14e6e383e85 100644 --- a/homeassistant/components/brottsplatskartan/__init__.py +++ b/homeassistant/components/brottsplatskartan/__init__.py @@ -1 +1,21 @@ """The brottsplatskartan 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 brottsplatskartan from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload brottsplatskartan config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py new file mode 100644 index 00000000000..1de24ffa76c --- /dev/null +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -0,0 +1,97 @@ +"""Adds config flow for Brottsplatskartan integration.""" +from __future__ import annotations + +from typing import Any +import uuid + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LOCATION): selector.LocationSelector( + selector.LocationSelectorConfig(radius=False, icon="") + ), + vol.Optional(CONF_AREA, default="none"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=AREAS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="areas", + ) + ), + } +) + + +class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brottsplatskartan integration.""" + + VERSION = 1 + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + + if config.get(CONF_LATITUDE): + config[CONF_LOCATION] = { + CONF_LATITUDE: config[CONF_LATITUDE], + CONF_LONGITUDE: config[CONF_LONGITUDE], + } + if not config.get(CONF_AREA): + config[CONF_AREA] = "none" + else: + config[CONF_AREA] = config[CONF_AREA][0] + + return await self.async_step_user(user_input=config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + + if user_input is not None: + latitude: float | None = None + longitude: float | None = None + area: str | None = ( + user_input[CONF_AREA] if user_input[CONF_AREA] != "none" else None + ) + + if area: + name = f"{DEFAULT_NAME} {area}" + elif location := user_input.get(CONF_LOCATION): + lat: float = location[CONF_LATITUDE] + long: float = location[CONF_LONGITUDE] + latitude = lat + longitude = long + name = f"{DEFAULT_NAME} {round(latitude, 2)}, {round(longitude, 2)}" + else: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + name = f"{DEFAULT_NAME} HOME" + + app = f"ha-{uuid.getnode()}" + + self._async_abort_entries_match( + {CONF_AREA: area, CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude} + ) + return self.async_create_entry( + title=name, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_AREA: area, + CONF_APP_ID: app, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index 87c42b01f4b..8bd08f452f4 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -2,13 +2,19 @@ import logging +from homeassistant.const import Platform + +DOMAIN = "brottsplatskartan" +PLATFORMS = [Platform.SENSOR] + LOGGER = logging.getLogger(__package__) CONF_AREA = "area" +CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" AREAS = [ - "N/A", + "none", "Blekinge län", "Dalarnas län", "Gotlands län", diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 8007fb6d11a..14c4a5e39c2 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -1,7 +1,8 @@ { "domain": "brottsplatskartan", "name": "Brottsplatskartan", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index da53a9fc0ec..ca6173d2ef5 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,23 +3,29 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import uuid -import brottsplatskartan +from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME 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 -from .const import AREAS, CONF_AREA, DEFAULT_NAME, LOGGER +from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, @@ -29,39 +35,65 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Brottsplatskartan platform.""" - area = config.get(CONF_AREA) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config[CONF_NAME] - - # Every Home Assistant instance should have their own unique - # app parameter: https://brottsplatskartan.se/sida/api - app = f"ha-{uuid.getnode()}" - - bpk = brottsplatskartan.BrottsplatsKartan( - app=app, area=area, latitude=latitude, longitude=longitude + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", ) - add_entities([BrottsplatskartanSensor(bpk, name)], True) + 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: + """Set up the Brottsplatskartan sensor entry.""" + + area = entry.data.get(CONF_AREA) + latitude = entry.data.get(CONF_LATITUDE) + longitude = entry.data.get(CONF_LONGITUDE) + app = entry.data[CONF_APP_ID] + name = entry.title + + bpk = BrottsplatsKartan(app=app, area=area, latitude=latitude, longitude=longitude) + + async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id)], True) class BrottsplatskartanSensor(SensorEntity): """Representation of a Brottsplatskartan Sensor.""" - _attr_attribution = brottsplatskartan.ATTRIBUTION + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True - def __init__(self, bpk: brottsplatskartan.BrottsplatsKartan, name: str) -> None: + def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: """Initialize the Brottsplatskartan sensor.""" self._brottsplatskartan = bpk - self._attr_name = name + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Brottsplatskartan", + name=name, + ) def update(self) -> None: """Update device state.""" diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json new file mode 100644 index 00000000000..8d9677a0af4 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "data": { + "location": "[%key:common::config_flow::data::location%]", + "area": "Area" + }, + "data_description": { + "location": "Put marker on location to cover within 5km radius", + "area": "If area is selected, any marked location is ignored" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Brottsplatskartan YAML configuration is being removed", + "description": "Configuring Brottsplatskartan using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Brottsplatskartan YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, + "selector": { + "areas": { + "options": { + "none": "No area" + } + } + } +} diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 539aa112a06..1255def44cb 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -7,6 +7,7 @@ from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate from bthome_ble.parser import EncryptionScheme from homeassistant.components.bluetooth import ( + DOMAIN as BLUETOOTH_DOMAIN, BluetoothScanningMode, BluetoothServiceInfoBleak, ) @@ -16,8 +17,16 @@ from homeassistant.components.bluetooth.passive_update_processor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry, async_get -from .const import DOMAIN +from .const import ( + BTHOME_BLE_EVENT, + CONF_BINDKEY, + CONF_DISCOVERED_EVENT_CLASSES, + DOMAIN, + BTHomeBleEvent, +) +from .models import BTHomeData PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -29,10 +38,53 @@ def process_service_info( entry: ConfigEntry, data: BTHomeBluetoothDeviceData, service_info: BluetoothServiceInfoBleak, + device_registry: DeviceRegistry, ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) - # If that payload was encrypted and the bindkey was not verified then we need to reauth + domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id] + if update.events: + address = service_info.device.address + for device_key, event in update.events.items(): + sensor_device_info = update.devices[device_key.device_id] + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(BLUETOOTH_DOMAIN, address)}, + manufacturer=sensor_device_info.manufacturer, + model=sensor_device_info.model, + name=sensor_device_info.name, + sw_version=sensor_device_info.sw_version, + hw_version=sensor_device_info.hw_version, + ) + 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) + hass.config_entries.async_update_entry( + entry, + data=entry.data + | { + CONF_DISCOVERED_EVENT_CLASSES: list( + domain_data.discovered_event_classes + ) + }, + ) + + hass.bus.async_fire( + BTHOME_BLE_EVENT, + dict( + BTHomeBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + ), + ) + + # If payload is encrypted and the bindkey is not verified then we need to reauth if data.encryption_scheme != EncryptionScheme.NONE and not data.bindkey_verified: entry.async_start_reauth(hass, data={"device": data}) @@ -45,10 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None kwargs = {} - if bindkey := entry.data.get("bindkey"): - kwargs["bindkey"] = bytes.fromhex(bindkey) + if bindkey := entry.data.get(CONF_BINDKEY): + kwargs[CONF_BINDKEY] = bytes.fromhex(bindkey) data = BTHomeBluetoothDeviceData(**kwargs) + device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id ] = PassiveBluetoothProcessorCoordinator( @@ -57,11 +110,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: address=address, mode=BluetoothScanningMode.PASSIVE, update_method=lambda service_info: process_service_info( - hass, entry, data, service_info + hass, entry, data, service_info, device_registry ), 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() ) # only start after all platforms have had a chance to subscribe diff --git a/homeassistant/components/bthome/const.py b/homeassistant/components/bthome/const.py index e46aa50e148..75a8ab4fc86 100644 --- a/homeassistant/components/bthome/const.py +++ b/homeassistant/components/bthome/const.py @@ -1,3 +1,32 @@ """Constants for the BTHome Bluetooth integration.""" +from __future__ import annotations + +from typing import Final, TypedDict DOMAIN = "bthome" + +CONF_BINDKEY: Final = "bindkey" +CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" +CONF_SUBTYPE: Final = "subtype" + +EVENT_TYPE: Final = "event_type" +EVENT_CLASS: Final = "event_class" +EVENT_PROPERTIES: Final = "event_properties" +BTHOME_BLE_EVENT: Final = "bthome_ble_event" + + +EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_DIMMER: Final = "dimmer" + +CONF_EVENT_CLASS: Final = "event_class" +CONF_EVENT_PROPERTIES: Final = "event_properties" + + +class BTHomeBleEvent(TypedDict): + """BTHome BLE event data.""" + + device_id: str + address: str + event_class: str # ie 'button' + event_type: str # ie 'press' + event_properties: dict[str, str | int | float | None] | None diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py new file mode 100644 index 00000000000..a81c30eee85 --- /dev/null +++ b/homeassistant/components/bthome/device_trigger.py @@ -0,0 +1,130 @@ +"""Provides device triggers for BTHome BLE.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import ( + BTHOME_BLE_EVENT, + CONF_DISCOVERED_EVENT_CLASSES, + CONF_SUBTYPE, + DOMAIN, + EVENT_CLASS, + EVENT_CLASS_BUTTON, + EVENT_CLASS_DIMMER, + EVENT_TYPE, +) + +TRIGGERS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: { + "press", + "double_press", + "triple_press", + "long_press", + "long_double_press", + "long_triple_press", + }, + EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, +} + +SCHEMA_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), + vol.Required(CONF_SUBTYPE): vol.In( + TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_BUTTON] + ), + } + ), + EVENT_CLASS_DIMMER: DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_DIMMER]), + vol.Required(CONF_SUBTYPE): vol.In( + TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_DIMMER] + ), + } + ), +} + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( + config + ) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """Return a list of triggers for BTHome BLE devices.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + assert device is not None + config_entries = [ + hass.config_entries.async_get_entry(entry_id) + for entry_id in device.config_entries + ] + bthome_config_entry = next( + iter(entry for entry in config_entries if entry and entry.domain == DOMAIN), + None, + ) + assert bthome_config_entry is not None + return [ + { + # Required fields of TRIGGER_BASE_SCHEMA + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: event_class, + CONF_SUBTYPE: event_type, + } + for event_class in bthome_config_entry.data.get( + CONF_DISCOVERED_EVENT_CLASSES, [] + ) + for event_type in TRIGGERS_BY_EVENT_CLASS.get(event_class, []) + ] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + return await event_trigger.async_attach_trigger( + hass, + event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: CONF_EVENT, + event_trigger.CONF_EVENT_TYPE: BTHOME_BLE_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + EVENT_CLASS: config[CONF_TYPE], + EVENT_TYPE: config[CONF_SUBTYPE], + }, + } + ), + action, + trigger_info, + platform_type="device", + ) diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py new file mode 100644 index 00000000000..558f19c7742 --- /dev/null +++ b/homeassistant/components/bthome/models.py @@ -0,0 +1,11 @@ +"""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/strings.json b/homeassistant/components/bthome/strings.json index f2fdcc64826..020a0206e73 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -28,5 +28,21 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "device_automation": { + "trigger_subtype": { + "press": "Press", + "double_press": "Double Press", + "triple_press": "Triple Press", + "long_press": "Long Press", + "long_double_press": "Long Double Press", + "long_triple_press": "Long Triple Press", + "rotate_right": "Rotate Right", + "rotate_left": "Rotate Left" + }, + "trigger_type": { + "button": "Button \"{subtype}\"", + "dimmer": "Dimmer \"{subtype}\"" + } } } diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index 9055220b7ee..e259dbac692 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -12,7 +12,7 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up buienradar from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True @@ -20,7 +20,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_data = hass.data[DOMAIN].pop(entry.entry_id) + for platform in PLATFORMS: + if (data := entry_data.get(platform)) and ( + unsub := data.unsub_schedule_update + ): + unsub() return unload_ok diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index b7061abeab3..06b97cdedad 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -35,6 +35,7 @@ from homeassistant.const import ( CONF_NAME, DEGREE, PERCENTAGE, + Platform, UnitOfIrradiance, UnitOfLength, UnitOfPrecipitationDepth, @@ -47,7 +48,7 @@ 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 +from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN from .util import BrData _LOGGER = logging.getLogger(__name__) @@ -684,6 +685,7 @@ async def async_setup_entry( data = BrData(hass, coordinates, timeframe, entities) # schedule the first update in 1 minute from now: await data.schedule_update(1) + hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data class BrSensor(SensorEntity): diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 06cd1b32cf5..54f3732afe4 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -27,6 +27,7 @@ from buienradar.constants import ( from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -65,6 +66,7 @@ class BrData: self.hass = hass self.coordinates = coordinates self.timeframe = timeframe + self.unsub_schedule_update: CALLBACK_TYPE | None = None async def update_devices(self): """Update all devices/sensors.""" @@ -79,7 +81,9 @@ class BrData: """Schedule an update after minute minutes.""" _LOGGER.debug("Scheduling next update in %s minutes", minute) nxt = dt_util.utcnow() + timedelta(minutes=minute) - async_track_point_in_utc_time(self.hass, self.async_update, nxt) + self.unsub_schedule_update = async_track_point_in_utc_time( + self.hass, self.async_update, nxt + ) async def get_data(self, url): """Load data from specified url.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 4cee98c07b7..c2a276eed1c 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + Platform, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -100,6 +101,7 @@ async def async_setup_entry( # create weather data: data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) + hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data # create weather device: _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index aedfafbf368..0f047bf3758 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -164,7 +164,7 @@ def _validate_rrule(value: Any) -> str: try: rrulestr(value) except ValueError as err: - raise vol.Invalid(f"Invalid rrule: {str(err)}") from err + raise vol.Invalid(f"Invalid rrule '{value}': {err}") from err # Example format: FREQ=DAILY;UNTIL=... rule_parts = dict(s.split("=", 1) for s in value.split(";")) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b1e4768a0a7..c09586848df 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -56,6 +56,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import get_url +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -912,15 +913,16 @@ async def async_handle_snapshot_service( ) -> None: """Handle snapshot services calls.""" hass = camera.hass - filename = service_call.data[ATTR_FILENAME] + filename: Template = service_call.data[ATTR_FILENAME] filename.hass = hass snapshot_file = filename.async_render(variables={ATTR_ENTITY_ID: camera}) # check if we allow to access to that file if not hass.config.is_allowed_path(snapshot_file): - _LOGGER.error("Can't write %s, no access to path!", snapshot_file) - return + raise HomeAssistantError( + f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + ) image = await camera.async_camera_image() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8352b566afe..0af85fe9d4d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import 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 @@ -50,9 +50,9 @@ from .const import ( CONF_RELAYER_SERVER, CONF_REMOTE_SNI_SERVER, CONF_REMOTESTATE_SERVER, + CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, - CONF_VOICE_SERVER, DOMAIN, MODE_DEV, MODE_PROD, @@ -119,7 +119,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_REMOTE_SNI_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_THINGTALK_SERVER): str, - vol.Optional(CONF_VOICE_SERVER): str, + vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, } ) }, @@ -241,6 +241,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websession = async_get_clientsession(hass) client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) + cloud.iot.register_on_connect(client.on_cloud_connected) async def _shutdown(event): """Shutdown event.""" @@ -262,8 +263,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - loaded = False - async def async_startup_repairs(_=None) -> None: """Create repair issues after startup.""" if not cloud.is_logged_in: @@ -272,8 +271,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if subscription_info := await async_subscription_info(cloud): async_manage_legacy_subscription_issue(hass, subscription_info) - async def _on_connect(): - """Discover RemoteUI binary sensor.""" + loaded = False + + async def _on_start(): + """Discover platforms.""" nonlocal loaded # Prevent multiple discovery @@ -281,10 +282,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) - await async_load_platform(hass, Platform.STT, DOMAIN, {}, config) - await async_load_platform(hass, Platform.TTS, DOMAIN, {}, config) + stt_platform_loaded = asyncio.Event() + tts_platform_loaded = asyncio.Event() + stt_info = {"platform_loaded": stt_platform_loaded} + tts_info = {"platform_loaded": tts_platform_loaded} + await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) + await async_load_platform(hass, Platform.STT, DOMAIN, stt_info, config) + await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) + await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) + + async def _on_connect(): + """Handle cloud connect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED ) @@ -299,6 +308,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Update preferences.""" await prefs.async_update(remote_domain=cloud.remote.instance_domain) + cloud.register_on_start(_on_start) cloud.iot.register_on_connect(_on_connect) cloud.iot.register_on_disconnect(_on_disconnect) cloud.register_on_initialized(_on_initialized) @@ -311,7 +321,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, delay=timedelta(hours=STARTUP_REPAIR_DELAY), - action=async_startup_repairs, + action=HassJob( + async_startup_repairs, "cloud startup repairs", cancel_on_shutdown=True + ), ) return True diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b7..bfdd2e560a5 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,9 +20,20 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, + async_get_assistant_settings, + async_get_entity_settings, + async_listen_entity_updates, + async_should_expose, +) +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.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start +from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -30,21 +41,89 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "alert", + "automation", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "image_processing", + "input_boolean", + "input_button", + "input_number", + "light", + "lock", + "media_player", + "number", + "scene", + "script", + "switch", + "timer", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.TEMPERATURE, +} + + +def entity_supported(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + 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 + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + + class CloudAlexaConfig(alexa_config.AbstractConfig): """Alexa Configuration.""" @@ -64,7 +143,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,15 +194,56 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """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 + return + + for state in self.hass.states.async_all(): + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, state.entity_id) + if CLOUD_ALEXA in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_ALEXA, + state.entity_id, + self._should_expose_legacy(state.entity_id), + ) + for entity_id in self._prefs.alexa_entity_configs: + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_ALEXA in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_ALEXA, + entity_id, + self._should_expose_legacy(entity_id), + ) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() - async def hass_started(hass): + async def on_hass_started(hass): + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) + + async def on_hass_start(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) self.hass.bus.async_listen( @@ -131,14 +251,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -156,9 +273,23 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Backwards compat if (default_expose := self._prefs.alexa_default_expose) is None: - return not auxiliary_entity + return not auxiliary_entity and entity_supported(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and entity_supported(self.hass, entity_id) + ) + + @callback + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback def async_invalidate_access_token(self): @@ -233,32 +364,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return - await self.async_sync_entities() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 900779f6b01..631c0641b4f 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -136,8 +136,8 @@ class CloudClient(Interface): return self._google_config - async def cloud_started(self) -> None: - """When cloud is started.""" + async def on_cloud_connected(self) -> None: + """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_): @@ -181,6 +181,9 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) + async def cloud_started(self) -> None: + """When cloud is started.""" + async def cloud_stopped(self) -> None: """When the cloud is stopped.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28e..7aa39efbf07 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,8 @@ PREF_USERNAME = "username" PREF_REMOTE_DOMAIN = "remote_domain" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" +PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False @@ -56,7 +58,7 @@ CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTE_SNI_SERVER = "remote_sni_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_THINGTALK_SERVER = "thingtalk_server" -CONF_VOICE_SERVER = "voice_server" +CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" MODE_PROD = "production" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73af..dae1c00a33f 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,5 +1,6 @@ """Google config for Cloud.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from typing import Any @@ -7,8 +8,17 @@ from typing import Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, + async_get_entity_settings, + async_listen_entity_updates, + async_set_assistant_option, + async_should_expose, +) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -17,19 +27,92 @@ from homeassistant.core import ( 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.setup import async_setup_component from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + + +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_button", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "switch", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + device_class = get_device_class(hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +131,6 @@ class CloudGoogleConfig(AbstractConfig): self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,15 +170,73 @@ class CloudGoogleConfig(AbstractConfig): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """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 + return + + for state in self.hass.states.async_all(): + entity_id = state.entity_id + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_GOOGLE in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_GOOGLE, + entity_id, + self._should_expose_legacy(entity_id), + ) + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + async_set_assistant_option( + self.hass, + CLOUD_GOOGLE, + entity_id, + PREF_DISABLE_2FA, + _2fa_disabled, + ) + for entity_id in self._prefs.google_entity_configs: + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_GOOGLE in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_GOOGLE, + entity_id, + self._should_expose_legacy(entity_id), + ) + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + async_set_assistant_option( + self.hass, + CLOUD_GOOGLE, + entity_id, + PREF_DISABLE_2FA, + _2fa_disabled, + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() - async def hass_started(hass): + async def on_hass_started(hass: HomeAssistant) -> None: + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) + + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) # Remove any stored user agent id that is not ours remove_agent_user_ids = [] @@ -109,7 +248,6 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +261,11 @@ class CloudGoogleConfig(AbstractConfig): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -150,9 +285,22 @@ class CloudGoogleConfig(AbstractConfig): # Backwards compat if default_expose is None: - return not auxiliary_entity + return not auxiliary_entity and _supported_legacy(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and _supported_legacy(self.hass, entity_id) + ) + + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property def agent_user_id(self): @@ -168,11 +316,22 @@ class CloudGoogleConfig(AbstractConfig): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + try: + settings = async_get_entity_settings(self.hass, state.entity_id) + except HomeAssistantError: + # Handle the entity has been removed + return False + + 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): """Send a state report to Google.""" @@ -218,14 +377,6 @@ class CloudGoogleConfig(AbstractConfig): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - or self._cur_default_expose is not prefs.google_default_expose - ) and self._config["filter"].empty_filter: - self.async_schedule_google_sync_all() - if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +384,14 @@ class CloudGoogleConfig(AbstractConfig): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" @@ -263,7 +416,7 @@ class CloudGoogleConfig(AbstractConfig): self.async_schedule_google_sync_all() @callback - def _handle_device_registry_updated(self, event: Event) -> None: + async def _handle_device_registry_updated(self, event: Event) -> None: """Handle when device registry updated.""" if ( not self.enabled diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28a..f5d5c98fe1a 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,7 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping +from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus @@ -14,30 +16,34 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import assist_pipeline, conversation, websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, ) from homeassistant.components.google_assistant import helpers as google_helpers +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info +from .alexa_config import entity_supported as entity_supported_by_alexa from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +72,12 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) + websocket_api.async_register_command(hass, alexa_get) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -179,11 +186,32 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request, data): """Handle login request.""" + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None.""" + for pipeline in assist_pipeline.async_get_pipelines(hass): + if ( + pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT + and pipeline.stt_engine == DOMAIN + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + hass = request.app["hass"] cloud = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - return self.json({"success": True}) + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await assist_pipeline.async_setup_pipeline_store(hass) + new_cloud_pipeline_id: str | None = None + if (cloud_assist_pipeline(hass)) is None: + if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( + hass, DOMAIN, DOMAIN + ): + new_cloud_pipeline_id = cloud_pipeline.id + return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) class CloudLogoutView(HomeAssistantView): @@ -350,8 +378,6 @@ async def websocket_subscription( vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, - vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], - vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -484,6 +510,7 @@ async def _account_data(hass: HomeAssistant, cloud: Cloud): "logged_in": True, "prefs": client.prefs.as_dict(), "remote_certificate": certificate, + "remote_certificate_status": remote.certificate_status, "remote_connected": remote.is_connected, "remote_domain": remote.instance_domain, "http_use_ssl": hass.config.api.use_ssl, @@ -523,6 +550,59 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + PREF_DISABLE_2FA: assistant_options.get(PREF_DISABLE_2FA), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -558,8 +638,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -569,17 +648,53 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_id: str = msg["entity_id"] - await cloud.client.prefs.async_update_google_entity_config(**changes) + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + disable_2fa = msg[PREF_DISABLE_2FA] + if assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return + + exposed_entities.async_set_assistant_option( + hass, CLOUD_GOOGLE, entity_id, PREF_DISABLE_2FA, disable_2fa ) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/alexa/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def alexa_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single alexa entity.""" + entity_id: str = msg["entity_id"] + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa( + hass, entity_id + ): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Alexa", + ) + return + + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -611,35 +726,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2bff4003669..2dbbc81e4c6 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,12 +1,12 @@ { "domain": "cloud", "name": "Home Assistant Cloud", - "after_dependencies": ["google_assistant", "alexa"], + "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["http", "webhook"], + "dependencies": ["homeassistant", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.63.1"] + "requirements": ["hass-nabucasa==0.66.2"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7f27e7cf39b..75e1856503c 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,9 +20,9 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -29,14 +31,33 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_GOOGLE_SETTINGS_VERSION, PREF_REMOTE_DOMAIN, - PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 + + +class CloudPreferencesStore(Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) + old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + + return old_data class CloudPreferences: @@ -45,7 +66,9 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" self._hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = CloudPreferencesStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self._prefs = None self._listeners = [] self.last_updated: set[str] = set() @@ -79,14 +102,12 @@ class CloudPreferences: google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, - alexa_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=UNDEFINED, - alexa_default_expose=UNDEFINED, - google_default_expose=UNDEFINED, tts_default_voice=UNDEFINED, remote_domain=UNDEFINED, + alexa_settings_version=UNDEFINED, + google_settings_version=UNDEFINED, ): """Update user preferences.""" prefs = {**self._prefs} @@ -98,12 +119,10 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), - (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), - (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), - (PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -112,53 +131,6 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(google_entity_configs=updated_entities) - - async def async_update_alexa_entity_config( - self, *, entity_id, should_expose=UNDEFINED - ): - """Update config for an Alexa entity.""" - entities = self.alexa_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(alexa_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -186,14 +158,12 @@ class CloudPreferences: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, - PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, @@ -235,6 +205,11 @@ class CloudPreferences: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -255,6 +230,11 @@ class CloudPreferences: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -319,6 +299,7 @@ class CloudPreferences: return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, @@ -326,6 +307,7 @@ class CloudPreferences: PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 432a4db0f77..a3cf7fe0457 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -4,6 +4,7 @@ "can_reach_cert_server": "Reach Certificate Server", "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", + "certificate_status": "Certificate Status", "relayer_connected": "Relayer Connected", "relayer_region": "Relayer Region", "remote_connected": "Remote Connected", diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 13062db57d6..84e1e088d47 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -5,7 +5,7 @@ from collections.abc import AsyncIterable import logging from hass_nabucasa import Cloud -from hass_nabucasa.voice import VoiceError +from hass_nabucasa.voice import STT_LANGUAGES, VoiceError from homeassistant.components.stt import ( AudioBitRates, @@ -23,35 +23,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SUPPORT_LANGUAGES = [ - "da-DK", - "de-DE", - "en-AU", - "en-CA", - "en-GB", - "en-US", - "es-ES", - "fi-FI", - "fr-CA", - "fr-FR", - "it-IT", - "ja-JP", - "nl-NL", - "pl-PL", - "pt-PT", - "ru-RU", - "sv-SE", - "th-TH", - "zh-CN", - "zh-HK", -] - async def async_get_engine(hass, config, discovery_info=None): """Set up Cloud speech component.""" cloud: Cloud = hass.data[DOMAIN] - return CloudProvider(cloud) + cloud_provider = CloudProvider(cloud) + if discovery_info is not None: + discovery_info["platform_loaded"].set() + return cloud_provider class CloudProvider(Provider): @@ -64,7 +44,7 @@ class CloudProvider(Provider): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return SUPPORT_LANGUAGES + return STT_LANGUAGES @property def supported_formats(self) -> list[AudioFormats]: @@ -95,7 +75,7 @@ class CloudProvider(Provider): self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" - content = ( + content_type = ( f"audio/{metadata.format!s}; codecs=audio/{metadata.codec!s};" " samplerate=16000" ) @@ -103,10 +83,12 @@ class CloudProvider(Provider): # Process STT try: result = await self.cloud.voice.process_stt( - stream, content, metadata.language + stream=stream, + content_type=content_type, + language=metadata.language, ) except VoiceError as err: - _LOGGER.debug("Voice error: %s", err) + _LOGGER.error("Voice error: %s", err) return SpeechResult(None, SpeechResultState.ERROR) # Return Speech as Text diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index b1f1774aa47..592338144f3 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -34,6 +34,7 @@ async def system_health_info(hass): data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled data["remote_server"] = cloud.remote.snitun_server + data["certificate_status"] = cloud.remote.certificate_status data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, f"https://{cloud.acme_server}/directory" diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index bbf4ef287d6..fea2ffca987 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,16 +1,28 @@ """Support for the cloud for text to speech service.""" +import logging + from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError +from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + ATTR_AUDIO_OUTPUT, + ATTR_VOICE, + CONF_LANG, + PLATFORM_SCHEMA, + Provider, + Voice, +) +from homeassistant.core import callback from .const import DOMAIN -CONF_GENDER = "gender" +ATTR_GENDER = "gender" -SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE}) +SUPPORT_LANGUAGES = list(TTS_VOICES) + +_LOGGER = logging.getLogger(__name__) def validate_lang(value): @@ -18,8 +30,8 @@ def validate_lang(value): if (lang := value.get(CONF_LANG)) is None: return value - if (gender := value.get(CONF_GENDER)) is None: - gender = value[CONF_GENDER] = next( + if (gender := value.get(ATTR_GENDER)) is None: + gender = value[ATTR_GENDER] = next( (chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None ) @@ -33,7 +45,7 @@ PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, - vol.Optional(CONF_GENDER): str, + vol.Optional(ATTR_GENDER): str, } ), validate_lang, @@ -49,9 +61,12 @@ async def async_get_engine(hass, config, discovery_info=None): gender = None else: language = config[CONF_LANG] - gender = config[CONF_GENDER] + gender = config[ATTR_GENDER] - return CloudProvider(cloud, language, gender) + cloud_provider = CloudProvider(cloud, language, gender) + if discovery_info is not None: + discovery_info["platform_loaded"].set() + return cloud_provider class CloudProvider(Provider): @@ -87,24 +102,36 @@ class CloudProvider(Provider): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [CONF_GENDER] + return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + if not (voices := TTS_VOICES.get(language)): + return None + return [Voice(voice, voice) for voice in voices] @property def default_options(self): """Return a dict include default options.""" - return {CONF_GENDER: self._gender} + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } async def async_get_tts_audio(self, message, language, options=None): """Load TTS from NabuCasa Cloud.""" # Process TTS try: data = await self.cloud.voice.process_tts( - message, - language, - gender=options[CONF_GENDER], - output=AudioOutput.MP3, + text=message, + language=language, + gender=options.get(ATTR_GENDER), + voice=options.get(ATTR_VOICE), + output=options[ATTR_AUDIO_OUTPUT], ) - except VoiceError: + except VoiceError as err: + _LOGGER.error("Voice error: %s", err) return (None, None) - return ("mp3", data) + return (str(options[ATTR_AUDIO_OUTPUT]), data) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 680220371b4..9f133c0b0ca 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -35,13 +35,13 @@ class CO2SensorEntityDescription(SensorEntityDescription): SENSORS = ( CO2SensorEntityDescription( key="carbonIntensity", - name="CO2 intensity", + translation_key="carbon_intensity", unique_id="co2intensity", # No unit, it's extracted from response. ), CO2SensorEntityDescription( key="fossilFuelPercentage", - name="Grid fossil fuel percentage", + translation_key="fossil_fuel_percentage", native_unit_of_measurement=PERCENTAGE, ), ) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 2fe5b79c907..05ea76f3179 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -30,5 +30,11 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "api_ratelimit": "API Ratelimit exceeded" } + }, + "entity": { + "sensor": { + "carbon_intensity": { "name": "CO2 intensity" }, + "fossil_fuel_percentage": { "name": "Grid fossil fuel percentage" } + } } } diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 88715ab876e..c6fd4003156 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -535,8 +535,8 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict: "source": entry.source, "state": entry.state.value, "supports_options": supports_options, - "supports_remove_device": entry.supports_remove_device, - "supports_unload": entry.supports_unload, + "supports_remove_device": entry.supports_remove_device or False, + "supports_unload": entry.supports_unload or False, "pref_disable_new_entities": entry.pref_disable_new_entities, "pref_disable_polling": entry.pref_disable_polling, "disabled_by": entry.disabled_by, diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index bab8c8634ca..3d360e36438 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -1,5 +1,8 @@ """Provides data updates from the Control4 controller for platforms.""" +from collections import defaultdict +from collections.abc import Set import logging +from typing import Any from pyControl4.account import C4Account from pyControl4.director import C4Director @@ -15,21 +18,28 @@ from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAI _LOGGER = logging.getLogger(__name__) -async def director_update_data( - hass: HomeAssistant, entry: ConfigEntry, var: str -) -> dict: - """Retrieve data from the Control4 director for update_coordinator.""" - # possibly implement usage of director_token_expiration to start - # token refresh without waiting for error to occur +async def _update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Retrieve data from the Control4 director.""" + director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(variable_names) + result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) + for item in data: + result_dict[item["id"]][item["varName"]] = item["value"] + return dict(result_dict) + + +async def update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Try to Retrieve data from the Control4 director for update_coordinator.""" try: - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) + return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: _LOGGER.info("Updating Control4 director token") await refresh_tokens(hass, entry) - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) - return {key["id"]: key for key in data} + return await _update_variables_for_config_entry(hass, entry, variable_names) async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 57486641196..fde9b00aba2 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from . import Control4Entity, get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN -from .director_utils import director_update_data +from .director_utils import update_variables_for_config_entry _LOGGER = logging.getLogger(__name__) @@ -47,14 +47,18 @@ async def async_setup_entry( async def async_update_data_non_dimmer(): """Fetch data from Control4 director for non-dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_NON_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err async def async_update_data_dimmer(): """Fetch data from Control4 director for dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -185,13 +189,15 @@ class Control4Light(Control4Entity, LightEntity): @property def is_on(self): """Return whether this light is on or off.""" - return self.coordinator.data[self._idx]["value"] > 0 + if self._is_dimmer: + return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx]["value"] * 2.55) + return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) return None @property diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652a..f156acfd568 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable +from dataclasses import dataclass import logging import re -from typing import Any +from typing import Any, Literal import voluptuous as vol @@ -12,13 +14,26 @@ from homeassistant import core from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .default_agent import DefaultAgent +from .const import HOME_ASSISTANT_AGENT +from .default_agent import DefaultAgent, async_setup as async_setup_default_agent + +__all__ = [ + "DOMAIN", + "HOME_ASSISTANT_AGENT", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] _LOGGER = logging.getLogger(__name__) @@ -78,7 +93,9 @@ CONFIG_SCHEMA = vol.Schema( @core.callback def _get_agent_manager(hass: HomeAssistant) -> AgentManager: """Get the active agent.""" - return AgentManager(hass) + manager = AgentManager(hass) + manager.async_setup() + return manager @core.callback @@ -102,6 +119,34 @@ def async_unset_agent( _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) +async def async_get_conversation_languages( + hass: HomeAssistant, agent_id: str | None = None +) -> set[str] | Literal["*"]: + """Return languages supported by conversation agents. + + If an agent is specified, returns a set of languages supported by that agent. + If no agent is specified, return a set with the union of languages supported by + all conversation agents. + """ + agent_manager = _get_agent_manager(hass) + languages = set() + + agent_ids: Iterable[str] + if agent_id is None: + agent_ids = iter(info.id for info in agent_manager.async_get_agent_info()) + else: + agent_ids = (agent_id,) + + for _agent_id in agent_ids: + agent = await agent_manager.async_get_agent(_agent_id) + if agent.supported_languages == MATCH_ALL: + return MATCH_ALL + for language_tag in agent.supported_languages: + languages.add(language_tag) + + return languages + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" agent_manager = _get_agent_manager(hass) @@ -218,24 +263,38 @@ async def websocket_get_agent_info( @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/list", + vol.Optional("language"): str, + vol.Optional("country"): str, } ) -@core.callback -def websocket_list_agents( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], +@websocket_api.async_response +async def websocket_list_agents( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """List available agents.""" + """List conversation agents and, optionally, if they support a given language.""" manager = _get_agent_manager(hass) - connection.send_result( - msg["id"], - { - "default_agent": manager.default_agent, - "agents": manager.async_get_agent_info(), - }, - ) + country = msg.get("country") + language = msg.get("language") + agents = [] + + for agent_info in manager.async_get_agent_info(): + agent = await manager.async_get_agent(agent_info.id) + + supported_languages = agent.supported_languages + if language and supported_languages != MATCH_ALL: + supported_languages = language_util.matches( + language, supported_languages, country + ) + + agent_dict: dict[str, Any] = { + "id": agent_info.id, + "name": agent_info.name, + "supported_languages": supported_languages, + } + agents.append(agent_dict) + + connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) class ConversationProcessView(http.HomeAssistantView): @@ -270,6 +329,32 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +@dataclass(frozen=True) +class AgentInfo: + """Container for conversation agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info.id == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -299,8 +384,6 @@ async def async_converse( class AgentManager: """Class to manage conversation agents.""" - HOME_ASSISTANT_AGENT = "homeassistant" - default_agent: str = HOME_ASSISTANT_AGENT _builtin_agent: AbstractConversationAgent | None = None @@ -308,7 +391,11 @@ class AgentManager: """Initialize the conversation agents.""" self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} - self._default_agent_init_lock = asyncio.Lock() + self._builtin_agent_init_lock = asyncio.Lock() + + def async_setup(self) -> None: + """Set up the conversation agents.""" + async_setup_default_agent(self.hass) async def async_get_agent( self, agent_id: str | None = None @@ -317,11 +404,11 @@ class AgentManager: if agent_id is None: agent_id = self.default_agent - if agent_id == AgentManager.HOME_ASSISTANT_AGENT: + if agent_id == HOME_ASSISTANT_AGENT: if self._builtin_agent is not None: return self._builtin_agent - async with self._default_agent_init_lock: + async with self._builtin_agent_init_lock: if self._builtin_agent is not None: return self._builtin_agent @@ -332,50 +419,55 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ - { - "id": AgentManager.HOME_ASSISTANT_AGENT, - "name": "Home Assistant", - } + agents: list[AgentInfo] = [ + AgentInfo( + id=HOME_ASSISTANT_AGENT, + name="Home Assistant", + ) ] for agent_id, agent in self._agents.items(): config_entry = self.hass.config_entries.async_get_entry(agent_id) - # This is a bug, agent should have been unset when config entry was unloaded + # Guard against potential bugs in conversation agents where the agent is not + # removed from the manager when the config entry is removed if config_entry is None: _LOGGER.warning( - "Agent was still loaded while config entry is gone: %s", agent + "Conversation agent %s is still loaded after config entry removal", + agent, ) continue agents.append( - { - "id": agent_id, - "name": config_entry.title, - } + AgentInfo( + id=agent_id, + name=config_entry.title or config_entry.domain, + ) ) return agents @core.callback def async_is_valid_agent_id(self, agent_id: str) -> bool: """Check if the agent id is valid.""" - return agent_id in self._agents or agent_id == AgentManager.HOME_ASSISTANT_AGENT + return agent_id in self._agents or agent_id == HOME_ASSISTANT_AGENT @core.callback def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: """Set the agent.""" self._agents[agent_id] = agent - if self.default_agent == AgentManager.HOME_ASSISTANT_AGENT: + if self.default_agent == HOME_ASSISTANT_AGENT: self.default_agent = agent_id @core.callback def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" if self.default_agent == agent_id: - self.default_agent = AgentManager.HOME_ASSISTANT_AGENT + self.default_agent = HOME_ASSISTANT_AGENT self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 2b2c307f824..162338a6ff0 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -3,13 +3,13 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Any, Literal, TypedDict from homeassistant.core import Context from homeassistant.helpers import intent -@dataclass +@dataclass(slots=True) class ConversationInput: """User input to be processed.""" @@ -19,7 +19,7 @@ class ConversationInput: language: str -@dataclass +@dataclass(slots=True) class ConversationResult: """Result of async_process.""" @@ -49,6 +49,11 @@ class AbstractConversationAgent(ABC): """Return the attribution.""" return None + @property + @abstractmethod + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + @abstractmethod async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 1cae975c957..a8828fcc0e9 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,21 +1,5 @@ """Const for conversation integration.""" DOMAIN = "conversation" - -DEFAULT_EXPOSED_DOMAINS = { - "binary_sensor", - "climate", - "cover", - "fan", - "humidifier", - "light", - "lock", - "scene", - "script", - "sensor", - "switch", - "vacuum", - "water_heater", -} - DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} +HOME_ASSISTANT_AGENT = "homeassistant" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 98959320d7a..dccf394ab3f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -13,22 +13,29 @@ from typing import IO, Any from hassil.intents import Intents, ResponseType, SlotList, TextSlotList from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict -from home_assistant_intents import get_intents +from home_assistant_intents import get_domains_and_languages, get_intents import yaml from homeassistant import core, setup +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import MATCH_ALL from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, intent, + start, template, translation, ) +from homeassistant.helpers.event import async_track_state_change from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN +from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -37,17 +44,12 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) -def is_entity_exposed(state: core.State) -> bool: - """Return true if entity belongs to exposed domain list.""" - return state.domain in DEFAULT_EXPOSED_DOMAINS - - def json_load(fp: IO[str]) -> JsonObjectType: """Wrap json_loads for get_intents.""" return json_loads_object(fp.read()) -@dataclass +@dataclass(slots=True) class LanguageIntents: """Loaded intents for a language.""" @@ -73,6 +75,34 @@ def _get_language_variations(language: str) -> Iterable[str]: yield lang +@core.callback +def async_setup(hass: core.HomeAssistant) -> None: + """Set up entity registry listener for the default agent.""" + entity_registry = er.async_get(hass) + for entity_id in entity_registry.entities: + 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, + ): + """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) + + @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) + + start.async_at_started(hass, async_hass_started) + + class DefaultAgent(AbstractConversationAgent): """Default agent for conversation agent.""" @@ -86,6 +116,11 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents: dict[str, Any] = {} self._slot_lists: dict[str, SlotList] | None = None + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return get_domains_and_languages()["homeassistant"] + async def async_initialize(self, config_intents): """Initialize the default agent.""" if "intent" not in self.hass.config.components: @@ -110,6 +145,9 @@ class DefaultAgent(AbstractConversationAgent): self._async_handle_state_changed, run_immediately=True, ) + async_listen_entity_updates( + self.hass, DOMAIN, self._async_exposed_entities_updated + ) async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" @@ -163,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent): user_input.text, user_input.context, language, + assistant=DOMAIN, ) except intent.IntentHandleError: _LOGGER.exception("Intent handling error") @@ -452,7 +491,7 @@ class DefaultAgent(AbstractConversationAgent): @core.callback def _async_handle_entity_registry_changed(self, event: core.Event) -> None: """Clear names list cache when an entity registry entry has changed.""" - if event.data["action"] == "update" and not any( + if event.data["action"] != "update" or not any( field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS ): return @@ -465,16 +504,23 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists = None + @core.callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self._slot_lists = None + def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists area_ids_with_entities: set[str] = set() + entity_registry = er.async_get(self.hass) states = [ - state for state in self.hass.states.async_all() if is_entity_exposed(state) + state + for state in self.hass.states.async_all() + if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - entities = er.async_get(self.hass) devices = dr.async_get(self.hass) # Gather exposed entity names @@ -484,35 +530,33 @@ class DefaultAgent(AbstractConversationAgent): context = {"domain": state.domain} if state.attributes: # Include some attributes - for attr_key, attr_value in state.attributes.items(): - if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES: + for attr in DEFAULT_EXPOSED_ATTRIBUTES: + if attr not in state.attributes: continue - context[attr_key] = attr_value + context[attr] = state.attributes[attr] - entity = entities.async_get(state.entity_id) - if entity is not None: - if entity.entity_category or entity.hidden: - # Skip configuration/diagnostic/hidden entities - continue - - if entity.aliases: - for alias in entity.aliases: - entity_names.append((alias, alias, context)) + entity = entity_registry.async_get(state.entity_id) + if not entity: # Default name entity_names.append((state.name, state.name, context)) + continue - if entity.area_id: - # Expose area too - area_ids_with_entities.add(entity.area_id) - elif entity.device_id: - # Check device for area as well - device = devices.async_get(entity.device_id) - if (device is not None) and device.area_id: - area_ids_with_entities.add(device.area_id) - else: - # Default name - entity_names.append((state.name, state.name, context)) + if entity.aliases: + for alias in entity.aliases: + entity_names.append((alias, alias, context)) + + # Default name + entity_names.append((state.name, state.name, context)) + + if entity.area_id: + # Expose area too + area_ids_with_entities.add(entity.area_id) + elif entity.device_id: + # Check device for area as well + device = devices.async_get(entity.device_id) + if (device is not None) and device.area_id: + area_ids_with_entities.add(device.area_id) # Gather areas from exposed entities areas = ar.async_get(self.hass) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0753fcd5af9..0221d80002c 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": ["http"], + "dependencies": ["homeassistant", "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.3.29"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"] } diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py deleted file mode 100644 index a3bc07ee0a1..00000000000 --- a/homeassistant/components/coronavirus/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -"""The Coronavirus integration.""" -from datetime import timedelta -import logging - -import async_timeout -import coronavirus - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - entity_registry as er, - update_coordinator, -) -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Coronavirus component.""" - # Make sure coordinator is initialized. - await get_coordinator(hass) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Coronavirus from a config entry.""" - if isinstance(entry.data["country"], int): - hass.config_entries.async_update_entry( - entry, data={**entry.data, "country": entry.title} - ) - - @callback - def _async_migrator(entity_entry: er.RegistryEntry): - """Migrate away from unstable ID.""" - country, info_type = entity_entry.unique_id.rsplit("-", 1) - if not country.isnumeric(): - return None - return {"new_unique_id": f"{entry.title}-{info_type}"} - - await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) - - if not entry.unique_id: - hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) - - coordinator = await get_coordinator(hass) - if not coordinator.last_update_success: - await coordinator.async_config_entry_first_refresh() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def get_coordinator( - hass: HomeAssistant, -) -> update_coordinator.DataUpdateCoordinator: - """Get the data update coordinator.""" - if DOMAIN in hass.data: - return hass.data[DOMAIN] - - async def async_get_cases(): - async with async_timeout.timeout(10): - return { - case.country: case - for case in await coronavirus.get_cases( - aiohttp_client.async_get_clientsession(hass) - ) - } - - hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - name=DOMAIN, - update_method=async_get_cases, - update_interval=timedelta(hours=1), - ) - await hass.data[DOMAIN].async_refresh() - return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py deleted file mode 100644 index 81e4f06f57f..00000000000 --- a/homeassistant/components/coronavirus/config_flow.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Config flow for Coronavirus integration.""" -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult - -from . import get_coordinator -from .const import DOMAIN, OPTION_WORLDWIDE - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Coronavirus.""" - - VERSION = 1 - - _options: dict[str, Any] | None = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if self._options is None: - coordinator = await get_coordinator(self.hass) - if not coordinator.last_update_success or coordinator.data is None: - return self.async_abort(reason="cannot_connect") - - self._options = {OPTION_WORLDWIDE: "Worldwide"} - for case in sorted( - coordinator.data.values(), key=lambda case: case.country - ): - self._options[case.country] = case.country - - if user_input is not None: - await self.async_set_unique_id(user_input["country"]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=self._options[user_input["country"]], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), - errors=errors, - ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py deleted file mode 100644 index e1ffa64e88c..00000000000 --- a/homeassistant/components/coronavirus/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for the Coronavirus integration.""" -from coronavirus import DEFAULT_SOURCE - -DOMAIN = "coronavirus" -OPTION_WORLDWIDE = "__worldwide" -ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json deleted file mode 100644 index a053b4056c0..00000000000 --- a/homeassistant/components/coronavirus/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "coronavirus", - "name": "Coronavirus (COVID-19)", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/coronavirus", - "iot_class": "cloud_polling", - "loggers": ["coronavirus"], - "requirements": ["coronavirus==1.1.1"] -} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py deleted file mode 100644 index 7fa7c5aed08..00000000000 --- a/homeassistant/components/coronavirus/sensor.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sensor platform for the Corona virus.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import get_coordinator -from .const import ATTRIBUTION, OPTION_WORLDWIDE - -SENSORS = { - "confirmed": "mdi:emoticon-neutral-outline", - "current": "mdi:emoticon-sad-outline", - "recovered": "mdi:emoticon-happy-outline", - "deaths": "mdi:emoticon-cry-outline", -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) - - async_add_entities( - CoronavirusSensor(coordinator, config_entry.data["country"], info_type) - for info_type in SENSORS - ) - - -class CoronavirusSensor(CoordinatorEntity, SensorEntity): - """Sensor representing corona virus data.""" - - _attr_attribution = ATTRIBUTION - _attr_native_unit_of_measurement = "people" - - def __init__(self, coordinator, country, info_type): - """Initialize coronavirus sensor.""" - super().__init__(coordinator) - self._attr_icon = SENSORS[info_type] - self._attr_unique_id = f"{country}-{info_type}" - if country == OPTION_WORLDWIDE: - self._attr_name = f"Worldwide Coronavirus {info_type}" - else: - self._attr_name = ( - f"{coordinator.data[country].country} Coronavirus {info_type}" - ) - - self.country = country - self.info_type = info_type - - @property - def available(self) -> bool: - """Return if sensor is available.""" - return self.coordinator.last_update_success and ( - self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE - ) - - @property - def native_value(self): - """State of the sensor.""" - if self.country == OPTION_WORLDWIDE: - sum_cases = 0 - for case in self.coordinator.data.values(): - if (value := getattr(case, self.info_type)) is None: - continue - sum_cases += value - - return sum_cases - - return getattr(self.coordinator.data[self.country], self.info_type) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json deleted file mode 100644 index e0b29d6c8db..00000000000 --- a/homeassistant/components/coronavirus/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Pick a country to monitor", - "data": { "country": "Country" } - } - }, - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 30238073b16..768491f6085 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -106,7 +106,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = CounterStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -118,7 +117,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -140,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class CounterStorageCollection(collection.StorageCollection): +class CounterStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -154,10 +153,10 @@ class CounterStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class Counter(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 9b2bb05bb0f..dd22821d5e4 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -82,21 +82,19 @@ async def async_get_actions( if supported_features & SUPPORT_SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - else: - if supported_features & SUPPORT_OPEN: - actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: - actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: - actions.append({**base_action, CONF_TYPE: "stop"}) + if supported_features & SUPPORT_OPEN: + actions.append({**base_action, CONF_TYPE: "open"}) + if supported_features & SUPPORT_CLOSE: + actions.append({**base_action, CONF_TYPE: "close"}) + if supported_features & SUPPORT_STOP: + actions.append({**base_action, CONF_TYPE: "stop"}) if supported_features & SUPPORT_SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - else: - if supported_features & SUPPORT_OPEN_TILT: - actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: - actions.append({**base_action, CONF_TYPE: "close_tilt"}) + if supported_features & SUPPORT_OPEN_TILT: + actions.append({**base_action, CONF_TYPE: "open_tilt"}) + if supported_features & SUPPORT_CLOSE_TILT: + actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26a..b4a33392894 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ _RESOURCE = "http://apilayer.net/api/live" DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ class CurrencylayerSensor(SensorEntity): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 5c759384795..7848949831b 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Configure Daikin AC", - "description": "Enter [%key:common::config_flow::data::ip%] of your Daikin AC.\n\nNote that [%key:common::config_flow::data::api_key%] and [%key:common::config_flow::data::password%] only are used by BRP072Cxx and SKYFi devices respectively.", + "description": "Enter the IP address of your Daikin AC.\n\nNote that API key and password are only used by BRP072Cxx and SKYFi devices respectively.", "data": { "host": "[%key:common::config_flow::data::host%]", "api_key": "[%key:common::config_flow::data::api_key%]", @@ -18,7 +18,7 @@ "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "api_password": "[%key:common::config_flow::error::invalid_auth%], use either API Key or Password.", + "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index bb8c8638241..4fe141c4943 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.6.6"] + "requirements": ["debugpy==1.6.7"] } diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index d977e8813da..f4af7337427 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -235,9 +235,15 @@ class DeconzGateway: ) -> None: """Handle signals of config entry being updated. - This is a static method because a class method (bound method), cannot be used with weak references. - Causes for this is either discovery updating host address or config entry options changing. + This is a static method because a class method (bound method), + cannot be used with weak references. + Causes for this is either discovery updating host address or + config entry options changing. """ + if entry.entry_id not in hass.data[DECONZ_DOMAIN]: + # A race condition can occur if multiple config entries are + # unloaded in parallel + return gateway = get_gateway_from_config_entry(hass, entry) if gateway.api.host != gateway.host: diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 5569f9d5e8a..61794e7c70a 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==110"], + "requirements": ["pydeconz==111"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e9088532754..136f582f5c7 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -33,7 +33,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, @@ -108,7 +110,7 @@ class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMi ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( DeconzSensorDescription[AirQuality]( key="air_quality", - supported_fn=lambda device: device.air_quality is not None, + supported_fn=lambda device: device.supports_air_quality, update_key="airquality", value_fn=lambda device: device.air_quality, instance_check=AirQuality, @@ -124,6 +126,39 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, ), + DeconzSensorDescription[AirQuality]( + key="air_quality_formaldehyde", + supported_fn=lambda device: device.air_quality_formaldehyde is not None, + update_key="airquality_formaldehyde_density", + value_fn=lambda device: device.air_quality_formaldehyde, + instance_check=AirQuality, + name_suffix="CH2O", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + DeconzSensorDescription[AirQuality]( + key="air_quality_co2", + supported_fn=lambda device: device.air_quality_co2 is not None, + update_key="airquality_co2_density", + value_fn=lambda device: device.air_quality_co2, + instance_check=AirQuality, + name_suffix="CO2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + DeconzSensorDescription[AirQuality]( + key="air_quality_pm2_5", + supported_fn=lambda device: device.pm_2_5 is not None, + update_key="pm2_5", + value_fn=lambda device: device.pm_2_5, + instance_check=AirQuality, + name_suffix="PM25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), DeconzSensorDescription[Consumption]( key="consumption", supported_fn=lambda device: device.consumption is not None, diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index a1add475948..684013a5633 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@home-assistant/core"], "dependencies": [ "application_credentials", + "assist_pipeline", "automation", "bluetooth", "cloud", diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 13e8e135394..82cb8eff625 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -36,6 +36,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.SELECT, Platform.SENSOR, Platform.SIREN, + Platform.STT, Platform.SWITCH, Platform.TEXT, Platform.UPDATE, diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 923092fad20..07a844c048c 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -13,8 +13,11 @@ from homeassistant.components.stt import ( SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_LANGUAGES = ["en", "de"] @@ -29,6 +32,62 @@ async def async_get_engine( return DemoProvider() +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Demo speech platform via config entry.""" + async_add_entities([DemoProviderEntity()]) + + +class DemoProviderEntity(SpeechToTextEntity): + """Demo speech API provider entity.""" + + _attr_name = "Demo STT" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] + + @property + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_STEREO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service.""" + + # Read available data + async for _ in stream: + pass + + return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) + + class DemoProvider(Provider): """Demo speech API provider.""" diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index adf91eb706b..e1cc278137c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import PLATFORM_SCHEMA, RestoreSensor, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -22,7 +22,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 ( @@ -126,7 +125,7 @@ async def async_setup_platform( async_add_entities([derivative]) -class DerivativeSensor(RestoreEntity, SensorEntity): +class DerivativeSensor(RestoreSensor, SensorEntity): """Representation of an derivative sensor.""" _attr_icon = ICON @@ -170,9 +169,13 @@ class DerivativeSensor(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: + restored_data = await self.async_get_last_sensor_data() + if restored_data: + self._attr_native_unit_of_measurement = ( + restored_data.native_unit_of_measurement + ) try: - self._state = Decimal(state.state) + self._state = Decimal(restored_data.native_value) # type: ignore[arg-type] except SyntaxError as err: _LOGGER.warning("Could not restore last state: %s", err) diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 35f1679a31b..7a4ee9d4fc3 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -35,7 +35,7 @@ "data_description": { "round": "[%key:component::derivative::config::step::user::data_description::round%]", "time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]", - "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]." + "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]" } } } diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 3465e9ae273..d5017ac2329 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -29,7 +29,10 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound -from homeassistant.requirements import async_get_integration_with_requirements +from homeassistant.requirements import ( + RequirementsNotFound, + async_get_integration_with_requirements, +) from .const import ( # noqa: F401 CONF_IS_OFF, @@ -171,6 +174,10 @@ async def async_get_device_automation_platform( raise InvalidDeviceAutomationConfig( f"Integration '{domain}' not found" ) from err + except RequirementsNotFound as err: + raise InvalidDeviceAutomationConfig( + f"Integration '{domain}' could not be loaded" + ) from err except ImportError as err: raise InvalidDeviceAutomationConfig( f"Integration '{domain}' does not support device automation " diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 10725cd0392..5d56548f0ec 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -47,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: component = hass.data[DOMAIN] = EntityComponent[BaseTrackerEntity]( LOGGER, DOMAIN, hass ) + component.register_shutdown() # Clean up old devices created by device tracker entities in the past. # Can be removed after 2022.6 diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5f2ada3a5a7..e27ff57f03f 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -25,10 +25,11 @@ from homeassistant.const import ( CONF_MAC, CONF_NAME, DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_STOP, STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_per_platform, @@ -216,7 +217,7 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) # Clean up stale devices - async_track_utc_time_change( + cancel_update_stale = async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5) ) @@ -235,6 +236,16 @@ async def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> No # restore await tracker.async_setup_tracked_device() + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the async_update_stale schedule. + """ + cancel_update_stale() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + @attr.s class DeviceTrackerPlatform: @@ -356,6 +367,27 @@ async def async_create_platform_type( return DeviceTrackerPlatform(p_type, platform, p_config) +def _load_device_names_and_attributes( + scanner: DeviceScanner, + device_name_uses_executor: bool, + extra_attributes_uses_executor: bool, + seen: set[str], + found_devices: list[str], +) -> tuple[dict[str, str | None], dict[str, dict[str, Any]]]: + """Load device names and attributes in a single executor job.""" + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + for mac in found_devices: + if device_name_uses_executor and mac not in seen: + host_name_by_mac[mac] = scanner.get_device_name(mac) + if extra_attributes_uses_executor: + try: + extra_attributes_by_mac[mac] = scanner.get_extra_attributes(mac) + except NotImplementedError: + extra_attributes_by_mac[mac] = {} + return host_name_by_mac, extra_attributes_by_mac + + @callback def async_setup_scanner_platform( hass: HomeAssistant, @@ -373,7 +405,7 @@ def async_setup_scanner_platform( scanner.hass = hass # Initial scan of each mac we also tell about host name for config - seen: Any = set() + seen: set[str] = set() async def async_device_tracker_scan(now: datetime | None) -> None: """Handle interval matches.""" @@ -391,15 +423,42 @@ def async_setup_scanner_platform( async with update_lock: found_devices = await scanner.async_scan_devices() + device_name_uses_executor = ( + scanner.async_get_device_name.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_device_name + ) + extra_attributes_uses_executor = ( + scanner.async_get_extra_attributes.__func__ # type: ignore[attr-defined] + is DeviceScanner.async_get_extra_attributes + ) + host_name_by_mac: dict[str, str | None] = {} + extra_attributes_by_mac: dict[str, dict[str, Any]] = {} + if device_name_uses_executor or extra_attributes_uses_executor: + ( + host_name_by_mac, + extra_attributes_by_mac, + ) = await hass.async_add_executor_job( + _load_device_names_and_attributes, + scanner, + device_name_uses_executor, + extra_attributes_uses_executor, + seen, + found_devices, + ) + for mac in found_devices: if mac in seen: host_name = None else: - host_name = await scanner.async_get_device_name(mac) + host_name = host_name_by_mac.get( + mac, await scanner.async_get_device_name(mac) + ) seen.add(mac) try: - extra_attributes = await scanner.async_get_extra_attributes(mac) + extra_attributes = extra_attributes_by_mac.get( + mac, await scanner.async_get_extra_attributes(mac) + ) except NotImplementedError: extra_attributes = {} @@ -423,7 +482,7 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval( + cancel_legacy_scan = async_track_time_interval( hass, async_device_tracker_scan, interval, @@ -431,6 +490,16 @@ def async_setup_scanner_platform( ) hass.async_create_task(async_device_tracker_scan(None)) + @callback + def _on_hass_stop(_: Event) -> None: + """Cleanup when Home Assistant stops. + + Cancel the legacy scan. + """ + cancel_legacy_scan() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + async def get_tracker(hass: HomeAssistant, config: ConfigType) -> DeviceTracker: """Create a tracker.""" diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 293763c890e..84f05b88384 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -11,16 +11,16 @@ "step": { "user": { "data": { - "username": "[%key:common::config_flow::data::email%] / devolo ID", + "username": "Email / devolo ID", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" + "mydevolo_url": "mydevolo URL" } }, "zeroconf_confirm": { "data": { - "username": "[%key:common::config_flow::data::email%] / devolo ID", + "username": "Email / devolo ID", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" + "mydevolo_url": "mydevolo URL" } } } diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index facc80ce8e4..7f41d2c1d3d 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -77,7 +77,7 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class DhcpServiceInfo(BaseServiceInfo): """Prepared info from dhcp entries.""" diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 204992b48fa..53b2478490d 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,7 +1,7 @@ """Starts a service to scan in intervals for new devices.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import json import logging from typing import NamedTuple @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_discover, async_load_platform @@ -202,7 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, service_details.component, service_details.platform, info, config ) - async def scan_devices(now): + async def scan_devices(now: datetime) -> None: """Scan for devices.""" try: results = await hass.async_add_executor_job( @@ -215,13 +215,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: logger.error("Network is unreachable") async_track_point_in_utc_time( - hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL + hass, scan_devices_job, dt_util.utcnow() + SCAN_INTERVAL ) @callback - def schedule_first(event): + def schedule_first(event: Event) -> None: """Schedule the first discovery when Home Assistant starts up.""" - async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) + async_track_point_in_utc_time(hass, scan_devices_job, dt_util.utcnow()) + + scan_devices_job = HassJob(scan_devices, cancel_on_shutdown=True) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 5ffa1d4f290..79c114e2f38 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "pillow==9.4.0"] + "requirements": ["pydoods==1.0.2", "pillow==9.5.0"] } diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220ab..b50bd604763 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_STOP_ID = "stopid" CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ class DublinPublicTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py new file mode 100644 index 00000000000..af8786f8d77 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -0,0 +1,33 @@ +"""Constants for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +CONF_REGION_NAME: Final = "region_name" + +ATTR_REGION_NAME: Final = "region_name" +ATTR_REGION_ID: Final = "region_id" +ATTR_LAST_UPDATE: Final = "last_update" +ATTR_WARNING_COUNT: Final = "warning_count" + +API_ATTR_WARNING_NAME: Final = "event" +API_ATTR_WARNING_TYPE: Final = "event_code" +API_ATTR_WARNING_LEVEL: Final = "level" +API_ATTR_WARNING_HEADLINE: Final = "headline" +API_ATTR_WARNING_DESCRIPTION: Final = "description" +API_ATTR_WARNING_INSTRUCTION: Final = "instruction" +API_ATTR_WARNING_START: Final = "start_time" +API_ATTR_WARNING_END: Final = "end_time" +API_ATTR_WARNING_PARAMETERS: Final = "parameters" +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_SCAN_INTERVAL: Final = timedelta(minutes=15) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a76b8eeee8d..2a22d5f8fb2 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,9 +1,9 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95"], + "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], - "requirements": ["dwdwfsapi==1.0.5"] + "requirements": ["dwdwfsapi==1.0.6"] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 531eb1b3707..054d9e5ca8b 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -10,9 +10,6 @@ Wetterwarnungen (Stufe 1) """ from __future__ import annotations -from datetime import timedelta -import logging - from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol @@ -28,33 +25,28 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -ATTR_REGION_NAME = "region_name" -ATTR_REGION_ID = "region_id" -ATTR_LAST_UPDATE = "last_update" -ATTR_WARNING_COUNT = "warning_count" - -API_ATTR_WARNING_NAME = "event" -API_ATTR_WARNING_TYPE = "event_code" -API_ATTR_WARNING_LEVEL = "level" -API_ATTR_WARNING_HEADLINE = "headline" -API_ATTR_WARNING_DESCRIPTION = "description" -API_ATTR_WARNING_INSTRUCTION = "instruction" -API_ATTR_WARNING_START = "start_time" -API_ATTR_WARNING_END = "end_time" -API_ATTR_WARNING_PARAMETERS = "parameters" -API_ATTR_WARNING_COLOR = "color" - -DEFAULT_NAME = "DWD-Weather-Warnings" - -CONF_REGION_NAME = "region_name" - -CURRENT_WARNING_SENSOR = "current_warning_level" -ADVANCE_WARNING_SENSOR = "advance_warning_level" - -SCAN_INTERVAL = timedelta(minutes=15) - +from .const import ( + ADVANCE_WARNING_SENSOR, + API_ATTR_WARNING_COLOR, + API_ATTR_WARNING_DESCRIPTION, + API_ATTR_WARNING_END, + API_ATTR_WARNING_HEADLINE, + API_ATTR_WARNING_INSTRUCTION, + API_ATTR_WARNING_LEVEL, + API_ATTR_WARNING_NAME, + API_ATTR_WARNING_PARAMETERS, + API_ATTR_WARNING_START, + API_ATTR_WARNING_TYPE, + ATTR_LAST_UPDATE, + ATTR_REGION_ID, + ATTR_REGION_NAME, + ATTR_WARNING_COUNT, + CONF_REGION_NAME, + CURRENT_WARNING_SENSOR, + DEFAULT_NAME, + DEFAULT_SCAN_INTERVAL, + LOGGER, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -169,7 +161,7 @@ class DwdWeatherWarningsSensor(SensorEntity): def update(self) -> None: """Get the latest data from the DWD-Weather-Warnings API.""" - _LOGGER.debug( + LOGGER.debug( "Update requested for %s (%s) by %s", self._api.api.warncell_name, self._api.api.warncell_id, @@ -185,8 +177,8 @@ class WrappedDwDWWAPI: """Initialize a DWD-Weather-Warnings wrapper.""" self.api = api - @Throttle(SCAN_INTERVAL) + @Throttle(DEFAULT_SCAN_INTERVAL) def update(self): """Get the latest data from the DWD-Weather-Warnings API.""" self.api.update() - _LOGGER.debug("Update performed") + LOGGER.debug("Update performed") diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 803530fd6f8..5755a1b3dbe 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.3"] + "requirements": ["easyenergy==0.3.0"] } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 0df5b9bd8c2..9cf5944dfaa 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -13,7 +13,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume +from homeassistant.const import ( + CURRENCY_EURO, + PERCENTAGE, + UnitOfEnergy, + UnitOfTime, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -175,6 +181,22 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_return, ), + EasyEnergySensorEntityDescription( + key="hours_priced_equal_or_lower", + name="Hours priced equal or lower than current - today", + service_type="today_energy_usage", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage, + ), + EasyEnergySensorEntityDescription( + key="hours_priced_equal_or_higher", + name="Hours priced equal or higher than current - today", + service_type="today_energy_return", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return, + ), ) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 10e7f06a15a..3472ca231e9 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.18"] + "requirements": ["pyeconet==0.1.20"] } diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index 075e8beb789..dba5d35ab1a 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", "iot_class": "local_polling", "loggers": ["beacontools"], - "requirements": ["beacontools[scan]==1.2.3", "construct==2.10.56"] + "requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 611c1b6ddfd..df9606475ff 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -413,12 +413,6 @@ class EDL21Entity(SensorEntity): self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() - self._state_attrs = { - "status": "status", - "valTime": "val_time", - "scaler": "scaler", - "valueSignature": "value_signature", - } self._async_remove_dispatcher = None self.entity_description = entity_description self._attr_unique_id = f"{electricity_id}_{obis}" @@ -462,15 +456,6 @@ class EDL21Entity(SensorEntity): """Return the value of the last received telegram.""" return self._telegram.get("value") - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Enumerate supported attributes.""" - return { - self._state_attrs[k]: v - for k, v in self._telegram.items() - if k in self._state_attrs - } - @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json index d836e64c40b..99f39c99cbc 100644 --- a/homeassistant/components/egardia/manifest.json +++ b/homeassistant/components/egardia/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/egardia", "iot_class": "local_polling", "loggers": ["pythonegardia"], - "requirements": ["pythonegardia==1.0.40"] + "requirements": ["pythonegardia==1.0.52"] } diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 33b8749cb48..40c84efc60e 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -83,6 +83,9 @@ class ElmaxArea(ElmaxEntity, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + # Elmax alarm panels do always require a code to be passed for disarm operations + if code is None or code == "": + raise ValueError("Please input the disarm code.") await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=AreaCommand.DISARM, diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index 71588b4687f..6eb4cd654c5 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -44,11 +44,14 @@ async def async_setup_entry( coordinator=coordinator, ) entities.append(entity) - async_add_entities(entities, True) - known_devices.update([e.unique_id for e in entities]) + + if entities: + async_add_entities(entities) + known_devices.update([e.unique_id for e in entities]) # Register a listener for the discovery of new devices - coordinator.async_add_listener(_discover_new_devices) + remove_handle = coordinator.async_add_listener(_discover_new_devices) + config_entry.async_on_unload(remove_handle) # Immediately run a discovery, so we don't need to wait for the next update _discover_new_devices() diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index f1ffe87fde9..5334da23125 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -11,6 +11,7 @@ from elmax_api.exceptions import ( ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError, + ElmaxPanelBusyError, ) from elmax_api.http import Elmax from elmax_api.model.actuator import Actuator @@ -124,6 +125,10 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): raise ConfigEntryAuthFailed("Refused username/password") from err except ElmaxApiError as err: raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err + except ElmaxPanelBusyError as err: + raise UpdateFailed( + "Communication with the panel failed, as it is currently busy" + ) from err except ElmaxNetworkError as err: raise UpdateFailed( "A network error occurred while communicating with Elmax cloud." diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 0c1a0148205..5b9bb3b1085 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -32,6 +32,14 @@ LOGIN_FORM_SCHEMA = vol.Schema( } ) +REAUTH_FORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_ELMAX_USERNAME): str, + vol.Required(CONF_ELMAX_PASSWORD): str, + vol.Required(CONF_ELMAX_PANEL_PIN): str, + } +) + def _store_panel_by_name( panel: PanelEntry, username: str, panel_names: dict[str, str] @@ -56,8 +64,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _password: str _panels_schema: vol.Schema _panel_names: dict - _reauth_username: str | None - _reauth_panelid: str | None + _entry: config_entries.ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -170,82 +177,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._reauth_username = entry_data.get(CONF_ELMAX_USERNAME) - self._reauth_panelid = entry_data.get(CONF_ELMAX_PANEL_ID) + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauthorization flow.""" errors = {} if user_input is not None: - panel_pin = user_input.get(CONF_ELMAX_PANEL_PIN) - password = user_input.get(CONF_ELMAX_PASSWORD) - entry = await self.async_set_unique_id(self._reauth_panelid) + username = user_input[CONF_ELMAX_USERNAME] + password = user_input[CONF_ELMAX_PASSWORD] + panel_pin = user_input[CONF_ELMAX_PANEL_PIN] # Handle authentication, make sure the panel we are re-authenticating against is listed among results # and verify its pin is correct. + assert self._entry is not None try: # Test login. - client = await self._async_login( - username=self._reauth_username, password=password - ) - + client = await self._async_login(username=username, password=password) # Make sure the panel we are authenticating to is still available. panels = [ p for p in await client.list_control_panels() - if p.hash == self._reauth_panelid + if p.hash == self._entry.data[CONF_ELMAX_PANEL_ID] ] if len(panels) < 1: raise NoOnlinePanelsError() - # Verify the pin is still valid.from + # Verify the pin is still valid. await client.get_panel_status( - control_panel_id=self._reauth_panelid, pin=panel_pin + control_panel_id=self._entry.data[CONF_ELMAX_PANEL_ID], + pin=panel_pin, ) - # If it is, proceed with configuration update. - self.hass.config_entries.async_update_entry( - entry, - data={ - CONF_ELMAX_PANEL_ID: self._reauth_panelid, - CONF_ELMAX_PANEL_PIN: panel_pin, - CONF_ELMAX_USERNAME: self._reauth_username, - CONF_ELMAX_PASSWORD: password, - }, - ) - await self.hass.config_entries.async_reload(entry.entry_id) - self._reauth_username = None - self._reauth_panelid = None - return self.async_abort(reason="reauth_successful") - except ElmaxBadLoginError: - _LOGGER.error( - "Wrong credentials or failed login while re-authenticating" - ) errors["base"] = "invalid_auth" except NoOnlinePanelsError: - _LOGGER.warning( - "Panel ID %s is no longer associated to this user", - self._reauth_panelid, - ) errors["base"] = "reauth_panel_disappeared" except ElmaxBadPinError: errors["base"] = "invalid_pin" - # We want the user to re-authenticate only for the given panel id using the same login. - # We pin them to the UI, so the user realizes she must log in with the appropriate credentials - # for the that specific panel. - schema = vol.Schema( - { - vol.Required(CONF_ELMAX_USERNAME): self._reauth_username, - vol.Required(CONF_ELMAX_PASSWORD): str, - vol.Required(CONF_ELMAX_PANEL_ID): self._reauth_panelid, - vol.Required(CONF_ELMAX_PANEL_PIN): str, - } - ) + # If all went right, update the config entry + if not errors: + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_ELMAX_PANEL_ID: self._entry.data[CONF_ELMAX_PANEL_ID], + CONF_ELMAX_PANEL_PIN: panel_pin, + CONF_ELMAX_USERNAME: username, + CONF_ELMAX_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + # Otherwise start over and show the relative error message return self.async_show_form( - step_id="reauth_confirm", data_schema=schema, errors=errors + step_id="reauth_confirm", data_schema=REAUTH_FORM_SCHEMA, errors=errors ) @staticmethod diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index 6c772776346..e6e8d76be91 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax_api==0.0.2"] + "requirements": ["elmax_api==0.0.4"] } diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index a9c823f3a1a..e8cdbe23a5c 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -15,6 +15,14 @@ "panel_id": "Panel ID", "panel_pin": "PIN Code" } + }, + "reauth_confirm": { + "description": "Please re-authenticate with the panel.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "panel_pin": "Panel Pin" + } } }, "error": { @@ -22,10 +30,12 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "network_error": "A network error occurred", "invalid_pin": "The provided pin is invalid", + "reauth_panel_disappeared": "The given panel is no longer associated to this user. Please log in using an account associated to this panel.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index e8de2986b95..431e75a0883 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -36,19 +36,24 @@ async def async_setup_entry( # Otherwise, add all the entities we found entities = [] for actuator in panel_status.actuators: + # Skip already handled devices + if actuator.endpoint_id in known_devices: + continue entity = ElmaxSwitch( panel=coordinator.panel_entry, elmax_device=actuator, panel_version=panel_status.release, coordinator=coordinator, ) - if entity.unique_id not in known_devices: - entities.append(entity) - async_add_entities(entities, True) - known_devices.update([entity.unique_id for entity in entities]) + entities.append(entity) + + if entities: + async_add_entities(entities) + known_devices.update([entity.unique_id for entity in entities]) # Register a listener for the discovery of new devices - coordinator.async_add_listener(_discover_new_devices) + remove_handle = coordinator.async_add_listener(_discover_new_devices) + config_entry.async_on_unload(remove_handle) # Immediately run a discovery, so we don't need to wait for the next update _discover_new_devices() diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 10ddf5bf675..eea3f18adc0 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.11.1"] + "requirements": ["sense_energy==0.11.2"] } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 6f16d2dc831..b2b29760e5e 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -74,7 +74,7 @@ async def async_setup_platform( await sensor_manager.async_start() -@dataclass +@dataclass(slots=True) class SourceAdapter: """Adapter to allow sources and their flows to be used as sensors.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0a89c3d9270..f1eb7591e83 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -107,7 +107,7 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] | return None -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class ValidationIssue: """Error or warning message.""" @@ -118,7 +118,7 @@ class ValidationIssue: translation_placeholders: dict[str, str] | None = None -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class ValidationIssues: """Container for validation issues.""" @@ -142,7 +142,7 @@ class ValidationIssues: issue.affected_entities.add((affected_entity, detail)) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class EnergyPreferencesValidation: """Dictionary holding validation information.""" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 17e0ed6e2ac..6262a28302f 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.33"] + "requirements": ["env_canada==0.5.34"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 4658893d375..a68dd562af1 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,12 +5,11 @@ from collections.abc import Callable import functools import logging import math -from typing import Any, Generic, NamedTuple, TypeVar, cast, overload +from typing import Any, Generic, NamedTuple, TypeVar, cast from aioesphomeapi import ( APIClient, APIConnectionError, - APIIntEnum, APIVersion, DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, @@ -23,6 +22,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -64,13 +64,15 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper +from .voice_assistant import VoiceAssistantUDPServer CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _R = TypeVar("_R") -STABLE_BLE_VERSION_STR = "2022.12.4" +STABLE_BLE_VERSION_STR = "2023.4.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", @@ -284,6 +286,49 @@ async def async_setup_entry( # noqa: C901 _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) ) + voice_assistant_udp_server: VoiceAssistantUDPServer | None = None + + def _handle_pipeline_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + cli.send_voice_assistant_event(event_type, data) + + def _handle_pipeline_finished() -> None: + nonlocal voice_assistant_udp_server + + entry_data.async_set_assist_pipeline_state(False) + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.close() + voice_assistant_udp_server = None + + async def _handle_pipeline_start() -> int | None: + """Start a voice assistant pipeline.""" + nonlocal voice_assistant_udp_server + + if voice_assistant_udp_server is not None: + return None + + voice_assistant_udp_server = VoiceAssistantUDPServer( + hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished + ) + port = await voice_assistant_udp_server.start_server() + + hass.async_create_background_task( + voice_assistant_udp_server.run_pipeline(), + "esphome.voice_assistant_udp_server.run_pipeline", + ) + entry_data.async_set_assist_pipeline_state(True) + + return port + + async def _handle_pipeline_stop() -> None: + """Stop a voice assistant pipeline.""" + nonlocal voice_assistant_udp_server + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.stop() + async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" nonlocal device_id @@ -328,6 +373,14 @@ async def async_setup_entry( # noqa: C901 await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states(async_on_state_subscription) + if device_info.voice_assistant_version: + entry_data.disconnect_callbacks.append( + await cli.subscribe_voice_assistant( + _handle_pipeline_start, + _handle_pipeline_stop, + ) + ) + hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", host, err) @@ -690,41 +743,6 @@ def esphome_state_property( return _wrapper -_EnumT = TypeVar("_EnumT", bound=APIIntEnum) -_ValT = TypeVar("_ValT") - - -class EsphomeEnumMapper(Generic[_EnumT, _ValT]): - """Helper class to convert between hass and esphome enum values.""" - - def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: - """Construct a EsphomeEnumMapper.""" - # Add none mapping - augmented_mapping: dict[ - _EnumT | None, _ValT | None - ] = mapping # type: ignore[assignment] - augmented_mapping[None] = None - - self._mapping = augmented_mapping - self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} - - @overload - def from_esphome(self, value: _EnumT) -> _ValT: - ... - - @overload - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - ... - - def from_esphome(self, value: _EnumT | None) -> _ValT | None: - """Convert from an esphome int representation to a hass string.""" - return self._mapping[value] - - def from_hass(self, value: _ValT) -> _EnumT: - """Convert from a hass string to a esphome int representation.""" - return self._inverse[value] - - ICON_SCHEMA = vol.Schema(cv.icon) @@ -886,3 +904,40 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if not self._static_info.entity_category: return None return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) + + +class EsphomeAssistEntity(Entity): + """Define a base entity for Assist Pipeline entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry_data: RuntimeEntryData) -> None: + """Initialize the binary sensor.""" + self._entry_data: RuntimeEntryData = entry_data + self._attr_unique_id = ( + f"{self._device_info.mac_address}-{self.entity_description.key}" + ) + + @property + def _device_info(self) -> EsphomeDeviceInfo: + assert self._entry_data.device_info is not None + return self._entry_data.device_info + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + ) + + @callback + def _update(self) -> None: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + await super().async_added_to_hass() + self.async_on_remove( + self._entry_data.async_subscribe_assist_pipeline_update(self._update) + ) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 1a930435e6d..77ec780acb3 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -6,13 +6,15 @@ from aioesphomeapi import BinarySensorInfo, BinarySensorState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, platform_async_setup_entry +from . import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry +from .domain_data import DomainData async def async_setup_entry( @@ -29,6 +31,11 @@ async def async_setup_entry( state_type=BinarySensorState, ) + entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_version: + async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)]) + class EsphomeBinarySensor( EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity @@ -59,3 +66,17 @@ class EsphomeBinarySensor( if self._static_info.is_status_binary_sensor: return True return super().available + + +class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): + """A binary sensor implementation for ESPHome for use with assist_pipeline.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._entry_data.assist_pipeline_state diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 19d59843746..6151ed30429 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -2,6 +2,7 @@ from __future__ import annotations from aioesphomeapi import BluetoothLEAdvertisement +from bluetooth_data_tools import int_to_bluetooth_address from homeassistant.components.bluetooth import BaseHaRemoteScanner from homeassistant.core import callback @@ -14,9 +15,8 @@ class ESPHomeScanner(BaseHaRemoteScanner): def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: """Call the registered callback.""" # The mac address is a uint64, but we need a string - mac_hex = f"{adv.address:012X}" self._async_on_advertisement( - f"{mac_hex[0:2]}:{mac_hex[2:4]}:{mac_hex[4:6]}:{mac_hex[6:8]}:{mac_hex[8:10]}:{mac_hex[10:12]}", + int_to_bluetooth_address(adv.address), adv.rssi, adv.name, adv.service_uuids, diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index bfb3dbb8668..e40df234d58 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -54,12 +54,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7a6027f946b..61d6262250c 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -99,6 +99,10 @@ class RuntimeEntryData: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + assist_pipeline_update_callbacks: list[Callable[[], None]] = field( + default_factory=list + ) + assist_pipeline_state: bool = False @property def name(self) -> str: @@ -153,6 +157,24 @@ class RuntimeEntryData: self._ble_connection_free_futures.append(fut) return await fut + @callback + def async_set_assist_pipeline_state(self, state: bool) -> None: + """Set the assist pipeline state.""" + self.assist_pipeline_state = state + for update_callback in self.assist_pipeline_update_callbacks: + update_callback() + + def async_subscribe_assist_pipeline_update( + self, update_callback: Callable[[], None] + ) -> Callable[[], None]: + """Subscribe to assist pipeline updates.""" + + def _unsubscribe() -> None: + self.assist_pipeline_update_callbacks.remove(update_callback) + + self.assist_pipeline_update_callbacks.append(update_callback) + return _unsubscribe + @callback def async_remove_entity( self, hass: HomeAssistant, component_key: str, key: int @@ -180,6 +202,10 @@ class RuntimeEntryData: if async_get_dashboard(hass): needed_platforms.add(Platform.UPDATE) + if self.device_info is not None and self.device_info.voice_assistant_version: + needed_platforms.add(Platform.BINARY_SENSOR) + needed_platforms.add(Platform.SELECT) + for info in infos: for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): if isinstance(info, info_type): diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py new file mode 100644 index 00000000000..566f0bc503b --- /dev/null +++ b/homeassistant/components/esphome/enum_mapper.py @@ -0,0 +1,39 @@ +"""Helper class to convert between Home Assistant and ESPHome enum values.""" + +from typing import Generic, TypeVar, overload + +from aioesphomeapi import APIIntEnum + +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") + + +class EsphomeEnumMapper(Generic[_EnumT, _ValT]): + """Helper class to convert between hass and esphome enum values.""" + + def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: + """Construct a EsphomeEnumMapper.""" + # Add none mapping + augmented_mapping: dict[ + _EnumT | None, _ValT | None + ] = mapping # type: ignore[assignment] + augmented_mapping[None] = None + + self._mapping = augmented_mapping + self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} + + @overload + def from_esphome(self, value: _EnumT) -> _ValT: + ... + + @overload + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + ... + + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + """Convert from an esphome int representation to a hass string.""" + return self._mapping[value] + + def from_hass(self, value: _ValT) -> _EnumT: + """Convert from a hass string to a esphome int representation.""" + return self._inverse[value] diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 27952d36c60..01060630964 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -22,12 +22,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bf3e269221e..3576dadd1c0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz"], "config_flow": true, - "dependencies": ["bluetooth"], + "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ { "registered_devices": true @@ -14,6 +14,10 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.6.1", "esphome-dashboard-api==1.2.3"], + "requirements": [ + "aioesphomeapi==13.7.2", + "bluetooth-data-tools==0.4.0", + "esphome-dashboard-api==1.2.3" + ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 673a90580e0..d818e040965 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -24,12 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 7379be33da2..3ca8e0b9728 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -11,12 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 79af0455346..e4cac21dbc8 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -3,12 +3,20 @@ from __future__ import annotations from aioesphomeapi import SelectInfo, SelectState +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from . import ( + EsphomeAssistEntity, + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) +from .domain_data import DomainData +from .entry_data import RuntimeEntryData async def async_setup_entry( @@ -27,6 +35,11 @@ async def async_setup_entry( state_type=SelectState, ) + entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_version: + async_add_entities([EsphomeAssistPipelineSelect(hass, entry_data)]) + class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): """A select implementation for esphome.""" @@ -47,3 +60,12 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._client.select_command(self._static_info.key, option) + + +class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): + """Pipeline selector for esphome devices.""" + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a pipeline selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 863096cb3b1..25a0bfaff7f 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -24,12 +24,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt from homeassistant.util.enum import try_parse_enum -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index ebbc97374c2..81350c2c653 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -46,10 +46,25 @@ }, "flow_title": "{name}" }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + }, "issues": { "ble_firmware_outdated": { "title": "Update {name} with ESPHome {version} or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device to ESPHome {version}, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." + "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." }, "api_password_deprecated": { "title": "API Password deprecated on {name}", diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index a0d7b031336..618e31024b1 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -13,7 +13,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -33,34 +33,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" - dashboard = async_get_dashboard(hass) - - if dashboard is None: + if (dashboard := async_get_dashboard(hass)) is None: return - entry_data = DomainData.get(hass).get_entry_data(entry) - unsub = None + unsubs: list[CALLBACK_TYPE] = [] - async def setup_update_entity() -> None: + @callback + def _async_setup_update_entity() -> None: """Set up the update entity.""" - nonlocal unsub - + nonlocal unsubs + assert dashboard is not None # Keep listening until device is available - if not entry_data.available: + if not entry_data.available or not dashboard.last_update_success: return - if unsub is not None: - unsub() # type: ignore[unreachable] + for unsub in unsubs: + unsub() + unsubs.clear() - assert dashboard is not None async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)]) - if entry_data.available: - await setup_update_entity() + if entry_data.available and dashboard.last_update_success: + _async_setup_update_entity() return - signal = f"esphome_{entry_data.entry_id}_on_device_update" - unsub = async_dispatcher_connect(hass, signal, setup_update_entity) + unsubs = [ + async_dispatcher_connect( + hass, entry_data.signal_device_updated, _async_setup_update_entity + ), + dashboard.async_add_listener(_async_setup_update_entity), + ] class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): @@ -87,7 +89,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): # If the device has deep sleep, we can't assume we can install updates # as the ESP will not be connectable (by design). - if coordinator.supports_update and not self._device_info.has_deep_sleep: + if ( + coordinator.last_update_success + and coordinator.supports_update + and not self._device_info.has_deep_sleep + ): self._attr_supported_features = UpdateEntityFeature.INSTALL @property diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py new file mode 100644 index 00000000000..aaa2dc80a78 --- /dev/null +++ b/homeassistant/components/esphome/voice_assistant.py @@ -0,0 +1,253 @@ +"""ESPHome voice assistant support.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable, Callable +import logging +import socket +from typing import cast + +from aioesphomeapi import VoiceAssistantEventType +import async_timeout + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + async_pipeline_from_audio_stream, + select as pipeline_select, +) +from homeassistant.components.media_player import async_process_play_media_url +from homeassistant.core import Context, HomeAssistant, callback + +from .const import DOMAIN +from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper + +_LOGGER = logging.getLogger(__name__) + +UDP_PORT = 0 # Set to 0 to let the OS pick a free random port +UDP_MAX_PACKET_SIZE = 1024 + +_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ + VoiceAssistantEventType, PipelineEventType +] = EsphomeEnumMapper( + { + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + } +) + + +class VoiceAssistantUDPServer(asyncio.DatagramProtocol): + """Receive UDP packets and forward them to the voice assistant.""" + + started = False + queue: asyncio.Queue[bytes] | None = None + transport: asyncio.DatagramTransport | None = None + remote_addr: tuple[str, int] | None = None + + def __init__( + self, + hass: HomeAssistant, + entry_data: RuntimeEntryData, + handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], + handle_finished: Callable[[], None], + ) -> None: + """Initialize UDP receiver.""" + self.context = Context() + self.hass = hass + + assert entry_data.device_info is not None + self.device_info = entry_data.device_info + + self.queue = asyncio.Queue() + self.handle_event = handle_event + self.handle_finished = handle_finished + self._tts_done = asyncio.Event() + + async def start_server(self) -> int: + """Start accepting connections.""" + + def accept_connection() -> VoiceAssistantUDPServer: + """Accept connection.""" + if self.started: + raise RuntimeError("Can only start once") + if self.queue is None: + raise RuntimeError("No longer accepting connections") + + self.started = True + return self + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + + sock.bind(("", UDP_PORT)) + + await asyncio.get_running_loop().create_datagram_endpoint( + accept_connection, sock=sock + ) + + return cast(int, sock.getsockname()[1]) + + @callback + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Store transport for later use.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + @callback + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Handle incoming UDP packet.""" + if not self.started: + return + if self.remote_addr is None: + self.remote_addr = addr + if self.queue is not None: + self.queue.put_nowait(data) + + def error_received(self, exc: Exception) -> None: + """Handle when a send or receive operation raises an OSError. + + (Other than BlockingIOError or InterruptedError.) + """ + _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) + self.handle_finished() + + @callback + def stop(self) -> None: + """Stop the receiver.""" + if self.queue is not None: + self.queue.put_nowait(b"") + self.started = False + + def close(self) -> None: + """Close the receiver.""" + if self.queue is not None: + self.queue = None + 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") + + while data := await self.queue.get(): + yield data + + def _event_callback(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + + try: + event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) + except KeyError: + _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + return + + data_to_send = None + 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_TTS_START: + assert event.data is not None + data_to_send = {"text": event.data["tts_input"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert event.data is not None + path = event.data["tts_output"]["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} + + if self.device_info.voice_assistant_version >= 2: + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert event.data is not None + data_to_send = { + "code": event.data["code"], + "message": event.data["message"], + } + self.handle_finished() + + self.handle_event(event_type, data_to_send) + + async def run_pipeline( + self, + pipeline_timeout: float = 30.0, + ) -> None: + """Run the Voice Assistant 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, + context=self.context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=self._iterate_packets(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.device_info.mac_address + ), + tts_audio_output=tts_audio_output, + ) + + # Block until TTS is done sending + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + _LOGGER.warning("Pipeline timeout") + finally: + self.handle_finished() + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to device via UDP.""" + try: + if self.transport is None: + return + + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) + + bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 + sample_offset = 0 + samples_left = len(audio_bytes) // bytes_per_sample + + while samples_left > 0: + bytes_offset = sample_offset * bytes_per_sample + chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] + samples_in_chunk = len(chunk) // bytes_per_sample + samples_left -= samples_in_chunk + + self.transport.sendto(chunk, self.remote_addr) + await asyncio.sleep( + samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99 + ) + + sample_offset += samples_in_chunk + + finally: + self._tts_done.set() diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json index 5232fadc428..ccf15144f9e 100644 --- a/homeassistant/components/eufy/manifest.json +++ b/homeassistant/components/eufy/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufy", "iot_class": "local_polling", "loggers": ["lakeside"], - "requirements": ["lakeside==0.12"] + "requirements": ["lakeside==0.13"] } diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 6dd2104bd9b..866be3fba54 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.6.9"] + "requirements": ["pyfibaro==0.7.0"] } diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6f290ccb293..9b1e2250a28 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -80,9 +80,7 @@ DEFAULT_FILTER_TIME_CONSTANT = 10 NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -FILTER_SCHEMA = vol.Schema( - {vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int)} -) +FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( { @@ -238,11 +236,18 @@ class SensorFilter(SensorEntity): self.async_write_ha_state() return - if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self._state = new_state.state + if new_state.state == STATE_UNKNOWN: + self._state = None self.async_write_ha_state() return + if new_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + temp_state = _State(new_state.last_updated, new_state.state) try: @@ -383,9 +388,9 @@ class FilterState: except ValueError: self.state = state.state - def set_precision(self, precision: int) -> None: + def set_precision(self, precision: int | None) -> None: """Set precision of Number based states.""" - if isinstance(self.state, Number): + if precision is not None and isinstance(self.state, Number): value = round(float(self.state), precision) self.state = int(value) if precision == 0 else value @@ -417,8 +422,8 @@ class Filter: self, name: str, window_size: int | timedelta, - precision: int, entity: str, + precision: int | None, ) -> None: """Initialize common attributes. @@ -467,6 +472,7 @@ class Filter: filtered = self._filter_state(fstate) filtered.set_precision(self.filter_precision) + if self._store_raw: self.states.append(copy(FilterState(new_state))) else: @@ -485,8 +491,9 @@ class RangeFilter(Filter, SensorEntity): def __init__( self, + *, entity: str, - precision: int, + precision: int | None = None, lower_bound: float | None = None, upper_bound: float | None = None, ) -> None: @@ -495,7 +502,9 @@ class RangeFilter(Filter, SensorEntity): :param upper_bound: band upper bound :param lower_bound: band lower bound """ - super().__init__(FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision, entity) + super().__init__( + FILTER_NAME_RANGE, DEFAULT_WINDOW_SIZE, precision=precision, entity=entity + ) self._lower_bound = lower_bound self._upper_bound = upper_bound self._stats_internal: Counter = Counter() @@ -539,13 +548,20 @@ class OutlierFilter(Filter, SensorEntity): """ def __init__( - self, window_size: int, precision: int, entity: str, radius: float + self, + *, + window_size: int, + entity: str, + radius: float, + precision: int | None = None, ) -> None: """Initialize Filter. :param radius: band radius """ - super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + super().__init__( + FILTER_NAME_OUTLIER, window_size, precision=precision, entity=entity + ) self._radius = radius self._stats_internal: Counter = Counter() self._store_raw = True @@ -579,10 +595,17 @@ class LowPassFilter(Filter, SensorEntity): """BASIC Low Pass Filter.""" def __init__( - self, window_size: int, precision: int, entity: str, time_constant: int + self, + *, + window_size: int, + entity: str, + time_constant: int, + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + super().__init__( + FILTER_NAME_LOWPASS, window_size, precision=precision, entity=entity + ) self._time_constant = time_constant def _filter_state(self, new_state: FilterState) -> FilterState: @@ -610,16 +633,19 @@ class TimeSMAFilter(Filter, SensorEntity): def __init__( self, + *, window_size: timedelta, - precision: int, entity: str, type: str, # pylint: disable=redefined-builtin + precision: int = DEFAULT_PRECISION, ) -> None: """Initialize Filter. :param type: type of algorithm used to connect discrete values """ - super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_SMA, window_size, precision=precision, entity=entity + ) self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() @@ -660,9 +686,13 @@ class ThrottleFilter(Filter, SensorEntity): One sample per window. """ - def __init__(self, window_size: int, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: int, entity: str, precision: None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_THROTTLE, window_size, precision=precision, entity=entity + ) self._only_numbers = False def _filter_state(self, new_state: FilterState) -> FilterState: @@ -683,9 +713,13 @@ class TimeThrottleFilter(Filter, SensorEntity): One sample per time period. """ - def __init__(self, window_size: timedelta, precision: int, entity: str) -> None: + def __init__( + self, *, window_size: timedelta, entity: str, precision: int | None = None + ) -> None: """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) + super().__init__( + FILTER_NAME_TIME_THROTTLE, window_size, precision=precision, entity=entity + ) self._time_window = window_size self._last_emitted_at: datetime | None = None self._only_numbers = False diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812fe..8091f8981e3 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ class ExchangeRateSensor(SensorEntity): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index e6f89536baf..94f50caa1a2 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -102,7 +102,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery ) async_track_time_interval( - hass, _async_start_background_discovery, DISCOVERY_INTERVAL + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, ) return True diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index c7663d6cf31..0e47fa9701b 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -22,16 +22,29 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( key="energy_production_today", name="Estimated energy production - today", - state=lambda estimate: estimate.energy_production_today / 1000, + state=lambda estimate: estimate.energy_production_today, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="energy_production_today_remaining", + name="Estimated energy production - remaining today", + state=lambda estimate: estimate.energy_production_today_remaining, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", name="Estimated energy production - tomorrow", - state=lambda estimate: estimate.energy_production_tomorrow / 1000, + state=lambda estimate: estimate.energy_production_tomorrow, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", @@ -84,15 +97,19 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( key="energy_current_hour", name="Estimated energy production - this hour", - state=lambda estimate: estimate.energy_current_hour / 1000, + state=lambda estimate: estimate.energy_current_hour, device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ForecastSolarSensorEntityDescription( key="energy_next_hour", - state=lambda estimate: estimate.sum_energy_production(1) / 1000, + state=lambda estimate: estimate.sum_energy_production(1), name="Estimated energy production - next hour", device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, ), ) diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index 7fdcd22d0fc..970747253df 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics( }, "data": { "energy_production_today": coordinator.data.energy_production_today, + "energy_production_today_remaining": coordinator.data.energy_production_today_remaining, "energy_production_tomorrow": coordinator.data.energy_production_tomorrow, "energy_current_hour": coordinator.data.energy_current_hour, "power_production_now": coordinator.data.power_production_now, @@ -45,9 +46,9 @@ async def async_get_config_entry_diagnostics( wh_datetime.isoformat(): wh_value for wh_datetime, wh_value in coordinator.data.wh_days.items() }, - "wh_hours": { + "wh_period": { wh_datetime.isoformat(): wh_value - for wh_datetime, wh_value in coordinator.data.wh_hours.items() + for wh_datetime, wh_value in coordinator.data.wh_period.items() }, }, "account": { diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py index 33537396330..b2e9b51473b 100644 --- a/homeassistant/components/forecast_solar/energy.py +++ b/homeassistant/components/forecast_solar/energy.py @@ -16,6 +16,6 @@ async def async_get_solar_forecast( return { "wh_hours": { timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_hours.items() + for timestamp, val in coordinator.data.wh_period.items() } } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 0b9abb5f45c..ac6a3f7c308 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["forecast_solar==2.2.0"] + "requirements": ["forecast_solar==3.0.0"] } diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py new file mode 100644 index 00000000000..9e833aca18b --- /dev/null +++ b/homeassistant/components/freebox/camera.py @@ -0,0 +1,122 @@ +"""Support for Freebox cameras.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.ffmpeg.camera import ( + CONF_EXTRA_ARGUMENTS, + CONF_INPUT, + DEFAULT_ARGUMENTS, + FFmpegCamera, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_DETECTION, DOMAIN +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up cameras.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked: set = set() + + @callback + def update_callback(): + add_entities(hass, router, async_add_entities, tracked) + + router.listeners.append( + async_dispatcher_connect(hass, router.signal_home_device_new, update_callback) + ) + update_callback() + + entity_platform.async_get_current_platform() + + +@callback +def add_entities(hass: HomeAssistant, router, async_add_entities, tracked): + """Add new cameras from the router.""" + new_tracked = [] + + for nodeid, node in router.home_devices.items(): + if (node["category"] != Platform.CAMERA) or (nodeid in tracked): + continue + new_tracked.append(FreeboxCamera(hass, router, node)) + tracked.add(nodeid) + + if new_tracked: + async_add_entities(new_tracked, True) + + +class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): + """Representation of a Freebox camera.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize a camera.""" + + super().__init__(hass, router, node) + device_info = { + CONF_NAME: node["label"].strip(), + CONF_INPUT: node["props"]["Stream"], + CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, + } + FFmpegCamera.__init__(self, hass, device_info) + + self._supported_features = ( + CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM + ) + + self._command_motion_detection = self.get_command_id( + node["type"]["endpoints"], ATTR_DETECTION + ) + self._attr_extra_state_attributes = {} + self.update_node(node) + + async def async_enable_motion_detection(self) -> None: + """Enable motion detection in the camera.""" + await self.set_home_endpoint_value(self._command_motion_detection, True) + self._attr_motion_detection_enabled = True + + async def async_disable_motion_detection(self) -> None: + """Disable motion detection in camera.""" + await self.set_home_endpoint_value(self._command_motion_detection, False) + self._attr_motion_detection_enabled = False + + async def async_update_signal(self) -> None: + """Update the camera node.""" + self.update_node(self._router.home_devices[self._id]) + self.async_write_ha_state() + + def update_node(self, node): + """Update params.""" + self._name = node["label"].strip() + + # Get status + if self._node["status"] == "active": + self._attr_is_streaming = True + else: + self._attr_is_streaming = False + + # Parse all endpoints values + for endpoint in filter( + lambda x: (x["ep_type"] == "signal"), node["show_endpoints"] + ): + self._attr_extra_state_attributes[endpoint["name"]] = endpoint["value"] + + # Get motion detection status + self._attr_motion_detection_enabled = self._attr_extra_state_attributes[ + ATTR_DETECTION + ] diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index dbee01c4e7d..af641b5430c 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -22,7 +22,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Freebox config flow.""" - self._host = None + self._host: str self._port = None def _show_setup_form(self, user_input=None, errors=None): @@ -42,9 +42,9 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is None: return self._show_setup_form(user_input, errors) @@ -58,7 +58,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input=None) -> FlowResult: """Attempt to link with the Freebox router. Given a configured host, will ask the user to press the button @@ -102,7 +102,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, user_input=None): + async def async_step_import(self, user_input=None) -> FlowResult: """Import a config entry.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 32cf407f2a9..767cb94de48 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -16,7 +16,13 @@ APP_DESC = { } API_VERSION = "v6" -PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, + Platform.CAMERA, +] DEFAULT_DEVICE_NAME = "Unknown device" @@ -27,7 +33,6 @@ STORAGE_VERSION = 1 CONNECTION_SENSORS_KEYS = {"rate_down", "rate_up"} - # Icons DEVICE_ICONS = { "freebox_delta": "mdi:television-guide", @@ -48,3 +53,20 @@ DEVICE_ICONS = { "vg_console": "mdi:gamepad-variant", "workstation": "mdi:desktop-tower-monitor", } + +ATTR_DETECTION = "detection" + + +CATEGORY_TO_MODEL = { + "pir": "F-HAPIR01A", + "camera": "F-HACAM01A", + "dws": "F-HADWS01A", + "kfb": "F-HAKFB01A", + "alarm": "F-MSEC07A", + "rts": "RTS", + "iohome": "IOHome", +} + +HOME_COMPATIBLE_PLATFORMS = [ + Platform.CAMERA, +] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py new file mode 100644 index 00000000000..c74f072a5be --- /dev/null +++ b/homeassistant/components/freebox/home_base.py @@ -0,0 +1,131 @@ +"""Support for Freebox base features.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import CATEGORY_TO_MODEL, DOMAIN +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + + +class FreeboxHomeEntity(Entity): + """Representation of a Freebox base entity.""" + + def __init__( + self, + hass: HomeAssistant, + router: FreeboxRouter, + node: dict[str, Any], + sub_node: dict[str, Any] | None = None, + ) -> None: + """Initialize a Freebox Home entity.""" + self._hass = hass + self._router = router + self._node = node + self._sub_node = sub_node + self._id = node["id"] + self._attr_name = node["label"].strip() + self._device_name = self._attr_name + self._attr_unique_id = f"{self._router.mac}-node_{self._id}" + + if sub_node is not None: + self._attr_name += " " + sub_node["label"].strip() + self._attr_unique_id += "-" + sub_node["name"].strip() + + self._available = True + self._firmware = node["props"].get("FwVersion") + self._manufacturer = "Freebox SAS" + self._remove_signal_update: Any + + self._model = CATEGORY_TO_MODEL.get(node["category"]) + if self._model is None: + if node["type"].get("inherit") == "node::rts": + self._manufacturer = "Somfy" + self._model = CATEGORY_TO_MODEL.get("rts") + elif node["type"].get("inherit") == "node::ios": + self._manufacturer = "Somfy" + self._model = CATEGORY_TO_MODEL.get("iohome") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._id)}, + manufacturer=self._manufacturer, + model=self._model, + name=self._device_name, + sw_version=self._firmware, + via_device=( + DOMAIN, + router.mac, + ), + ) + + async def async_update_signal(self): + """Update signal.""" + self._node = self._router.home_devices[self._id] + # Update name + if self._sub_node is None: + self._attr_name = self._node["label"].strip() + else: + self._attr_name = ( + self._node["label"].strip() + " " + self._sub_node["label"].strip() + ) + self.async_write_ha_state() + + async def set_home_endpoint_value(self, command_id: Any, value=None) -> None: + """Set Home endpoint value.""" + if command_id is None: + _LOGGER.error("Unable to SET a value through the API. Command is None") + return + await self._router.home.set_home_endpoint_value( + self._id, command_id, {"value": value} + ) + + def get_command_id(self, nodes, name) -> int | None: + """Get the command id.""" + node = next( + filter(lambda x: (x["name"] == name), nodes), + None, + ) + if not node: + _LOGGER.warning("The Freebox Home device has no value for: %s", name) + return None + return node["id"] + + async def async_added_to_hass(self): + """Register state update callback.""" + self.remove_signal_update( + async_dispatcher_connect( + self._hass, + self._router.signal_home_device_update, + self.async_update_signal, + ) + ) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self._remove_signal_update() + + def remove_signal_update(self, dispacher: Any): + """Register state update callback.""" + self._remove_signal_update = dispacher + + def get_value(self, ep_type, name): + """Get the value.""" + node = next( + filter( + lambda x: (x["name"] == name and x["ep_type"] == ep_type), + self._node["show_endpoints"], + ), + None, + ) + if not node: + _LOGGER.warning( + "The Freebox Home device has no node for: " + ep_type + "/" + name + ) + return None + return node.get("value") diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 637f7050bf6..ad7da1703b8 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -3,6 +3,7 @@ "name": "Freebox", "codeowners": ["@hacf-fr", "@Quentame"], "config_flow": true, + "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 0fb0f10a27d..5622da48e67 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -4,14 +4,16 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from datetime import datetime +import logging import os from pathlib import Path from typing import Any from freebox_api import Freepybox from freebox_api.api.call import Call +from freebox_api.api.home import Home from freebox_api.api.wifi import Wifi -from freebox_api.exceptions import NotOpenError +from freebox_api.exceptions import HttpRequestError, NotOpenError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -27,10 +29,13 @@ from .const import ( APP_DESC, CONNECTION_SENSORS_KEYS, DOMAIN, + HOME_COMPATIBLE_PLATFORMS, STORAGE_KEY, STORAGE_VERSION, ) +_LOGGER = logging.getLogger(__name__) + async def get_api(hass: HomeAssistant, host: str) -> Freepybox: """Get the Freebox API.""" @@ -70,11 +75,15 @@ class FreeboxRouter: self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] + self.home_granted = True + self.home_devices: dict[str, Any] = {} + self.listeners: list[dict[str, Any]] = [] async def update_all(self, now: datetime | None = None) -> None: """Update all Freebox platforms.""" await self.update_device_trackers() await self.update_sensors() + await self.update_home_devices() async def update_device_trackers(self) -> None: """Update Freebox devices.""" @@ -146,6 +155,30 @@ class FreeboxRouter: for fbx_disk in fbx_disks: self.disks[fbx_disk["id"]] = fbx_disk + async def update_home_devices(self) -> None: + """Update Home devices (alarm, light, sensor, switch, remote ...).""" + if not self.home_granted: + return + + try: + home_nodes: list[Any] = await self.home.get_home_nodes() or [] + except HttpRequestError: + self.home_granted = False + _LOGGER.warning("Home access is not granted") + return + + new_device = False + for home_node in home_nodes: + if home_node["category"] in HOME_COMPATIBLE_PLATFORMS: + if self.home_devices.get(home_node["id"]) is None: + new_device = True + self.home_devices[home_node["id"]] = home_node + + async_dispatcher_send(self.hass, self.signal_home_device_update) + + if new_device: + async_dispatcher_send(self.hass, self.signal_home_device_new) + async def reboot(self) -> None: """Reboot the Freebox.""" await self._api.system.reboot() @@ -172,6 +205,11 @@ class FreeboxRouter: """Event specific per Freebox entry to signal new device.""" return f"{DOMAIN}-{self._host}-device-new" + @property + def signal_home_device_new(self) -> str: + """Event specific per Freebox entry to signal new home device.""" + return f"{DOMAIN}-{self._host}-home-device-new" + @property def signal_device_update(self) -> str: """Event specific per Freebox entry to signal updates in devices.""" @@ -182,6 +220,11 @@ class FreeboxRouter: """Event specific per Freebox entry to signal updates in sensors.""" return f"{DOMAIN}-{self._host}-sensor-update" + @property + def signal_home_device_update(self) -> str: + """Event specific per Freebox entry to signal update in home devices.""" + return f"{DOMAIN}-{self._host}-home-device-update" + @property def sensors(self) -> dict[str, Any]: """Return sensors.""" @@ -196,3 +239,8 @@ class FreeboxRouter: def wifi(self) -> Wifi: """Return the wifi.""" return self._api.wifi + + @property + def home(self) -> Home: + """Return the home.""" + return self._api.home diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 4d5ba490faf..488d2d48f8c 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -113,6 +113,7 @@ class FreeboxSensor(SensorEntity): self.entity_description = description self._router = router self._attr_unique_id = f"{router.mac} {description.name}" + self._attr_device_info = router.device_info @callback def async_update_state(self) -> None: @@ -123,11 +124,6 @@ class FreeboxSensor(SensorEntity): else: self._attr_native_value = state - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info - @callback def async_on_demand_update(self): """Update state.""" @@ -193,19 +189,18 @@ class FreeboxDiskSensor(FreeboxSensor): self._disk = disk self._partition = partition self._attr_name = f"{partition['label']} {description.name}" - self._attr_unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}" + self._attr_unique_id = ( + f"{router.mac} {description.key} {disk['id']} {partition['id']}" + ) - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._disk["id"])}, - model=self._disk["model"], - name=f"Disk {self._disk['id']}", - sw_version=self._disk["firmware"], + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, disk["id"])}, + model=disk["model"], + name=f"Disk {disk['id']}", + sw_version=disk["firmware"], via_device=( DOMAIN, - self._router.mac, + router.mac, ), ) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d2edb99e026..6d371a82c95 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -80,7 +80,10 @@ class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if isinstance( - state := self.coordinator.data.get(self.entity_description.key), bool + state := self.coordinator.data["entity_states"].get( + self.entity_description.key + ), + bool, ): return state return None diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 89a51581bf7..821b53f7e12 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -19,6 +19,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +import xmltodict from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, @@ -137,8 +138,15 @@ class HostInfo(TypedDict): status: bool +class UpdateCoordinatorDataType(TypedDict): + """Update coordinator data type.""" + + call_deflections: dict[int, dict] + entity_states: dict[str, StateType | bool] + + class FritzBoxTools( - update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] + update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] ): """FritzBoxTools class.""" @@ -173,6 +181,7 @@ class FritzBoxTools( self.password = password self.port = port self.username = username + self.has_call_deflections: bool = False self._model: str | None = None self._current_firmware: str | None = None self._latest_firmware: str | None = None @@ -243,6 +252,8 @@ class FritzBoxTools( ) self.device_is_router = self.fritz_status.has_wan_enabled + self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services + def register_entity_updates( self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] ) -> Callable[[], None]: @@ -259,20 +270,30 @@ class FritzBoxTools( self._entity_update_functions[key] = update_fn return unregister_entity_updates - async def _async_update_data(self) -> dict[str, bool | StateType]: + async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update FritzboxTools data.""" - enity_data: dict[str, bool | StateType] = {} + entity_data: UpdateCoordinatorDataType = { + "call_deflections": {}, + "entity_states": {}, + } try: await self.async_scan_devices() for key, update_fn in self._entity_update_functions.items(): _LOGGER.debug("update entity %s", key) - enity_data[key] = await self.hass.async_add_executor_job( + entity_data["entity_states"][ + key + ] = await self.hass.async_add_executor_job( update_fn, self.fritz_status, self.data.get(key) ) + if self.has_call_deflections: + entity_data[ + "call_deflections" + ] = await self.async_update_call_deflections() except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex - _LOGGER.debug("enity_data: %s", enity_data) - return enity_data + + _LOGGER.debug("enity_data: %s", entity_data) + return entity_data @property def unique_id(self) -> str: @@ -354,6 +375,23 @@ class FritzBoxTools( """Retrieve latest device information from the FRITZ!Box.""" return await self.hass.async_add_executor_job(self._update_device_info) + async def async_update_call_deflections( + self, + ) -> dict[int, dict[str, Any]]: + """Call GetDeflections action from X_AVM-DE_OnTel service.""" + raw_data = await self.hass.async_add_executor_job( + partial(self.connection.call_action, "X_AVM-DE_OnTel1", "GetDeflections") + ) + if not raw_data: + return {} + + xml_data = xmltodict.parse(raw_data["NewDeflectionList"]) + if xml_data.get("List") and (items := xml_data["List"].get("Item")) is not None: + if not isinstance(items, list): + items = [items] + return {int(item["DeflectionId"]): item for item in items} + return {} + async def _async_get_wan_access(self, ip_address: str) -> bool | None: """Get WAN access rule for given IP address.""" try: @@ -772,18 +810,6 @@ class AvmWrapper(FritzBoxTools): "WLANConfiguration", str(index), "GetInfo" ) - async def async_get_ontel_num_deflections(self) -> dict[str, Any]: - """Call GetNumberOfDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call( - "X_AVM-DE_OnTel", "1", "GetNumberOfDeflections" - ) - - async def async_get_ontel_deflections(self) -> dict[str, Any]: - """Call GetDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call("X_AVM-DE_OnTel", "1", "GetDeflections") - async def async_set_wlan_configuration( self, index: int, turn_on: bool ) -> dict[str, Any]: diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 2b156046098..d6b78c1cfc0 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -309,4 +309,4 @@ class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data.get(self.entity_description.key) + return self.coordinator.data["entity_states"].get(self.entity_description.key) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index a26a0b2313f..c8a7952ae2b 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -4,10 +4,8 @@ from __future__ import annotations import logging from typing import Any -import xmltodict - from homeassistant.components.network import async_get_source_ip -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -15,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .common import ( @@ -47,31 +46,15 @@ async def _async_deflection_entities_list( _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEFLECTION) - deflections_response = await avm_wrapper.async_get_ontel_num_deflections() - if not deflections_response: + if ( + call_deflections := avm_wrapper.data.get("call_deflections") + ) is None or not isinstance(call_deflections, dict): _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) return [] - _LOGGER.debug( - "Specific %s response: GetNumberOfDeflections=%s", - SWITCH_TYPE_DEFLECTION, - deflections_response, - ) - - if deflections_response["NewNumberOfDeflections"] == 0: - _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) - return [] - - if not (deflection_list := await avm_wrapper.async_get_ontel_deflections()): - return [] - - items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"] - if not isinstance(items, list): - items = [items] - return [ - FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, dict_of_deflection) - for dict_of_deflection in items + FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, cd_id) + for cd_id in call_deflections ] @@ -273,6 +256,61 @@ async def async_setup_entry( ) +class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity): + """Fritz switch coordinator base class.""" + + coordinator: AvmWrapper + entity_description: SwitchEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: SwitchEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @property + def data(self) -> dict[str, Any]: + """Return entity data from coordinator data.""" + raise NotImplementedError() + + @property + def available(self) -> bool: + """Return availability based on data availability.""" + return super().available and bool(self.data) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: + """Handle switch state change request.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Fritz switch base class.""" @@ -417,69 +455,51 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): return bool(resp is not None) -class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch): """Defines a FRITZ!Box Tools PortForward switch.""" + _attr_entity_category = EntityCategory.CONFIG + def __init__( self, avm_wrapper: AvmWrapper, device_friendly_name: str, - dict_of_deflection: Any, + deflection_id: int, ) -> None: """Init Fritxbox Deflection class.""" - self._avm_wrapper = avm_wrapper - - self.dict_of_deflection = dict_of_deflection - self._attributes = {} - self.id = int(self.dict_of_deflection["DeflectionId"]) - self._attr_entity_category = EntityCategory.CONFIG - - switch_info = SwitchInfo( - description=f"Call deflection {self.id}", - friendly_name=device_friendly_name, + self.deflection_id = deflection_id + description = SwitchEntityDescription( + key=f"call_deflection_{self.deflection_id}", + name=f"Call deflection {self.deflection_id}", icon="mdi:phone-forward", - type=SWITCH_TYPE_DEFLECTION, - callback_update=self._async_fetch_update, - callback_switch=self._async_switch_on_off_executor, ) - super().__init__(self._avm_wrapper, device_friendly_name, switch_info) + super().__init__(avm_wrapper, device_friendly_name, description) - async def _async_fetch_update(self) -> None: - """Fetch updates.""" + @property + def data(self) -> dict[str, Any]: + """Return call deflection data.""" + return self.coordinator.data["call_deflections"].get(self.deflection_id, {}) - resp = await self._avm_wrapper.async_get_ontel_deflections() - if not resp: - self._is_available = False - return + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return device attributes.""" + return { + "type": self.data["Type"], + "number": self.data["Number"], + "deflection_to_number": self.data["DeflectionToNumber"], + "mode": self.data["Mode"][1:], + "outgoing": self.data["Outgoing"], + "phonebook_id": self.data["PhonebookID"], + } - self.dict_of_deflection = xmltodict.parse(resp["NewDeflectionList"])["List"][ - "Item" - ] - if isinstance(self.dict_of_deflection, list): - self.dict_of_deflection = self.dict_of_deflection[self.id] + @property + def is_on(self) -> bool | None: + """Switch status.""" + return self.data.get("Enable") == "1" - _LOGGER.debug( - "Specific %s response: NewDeflectionList=%s", - SWITCH_TYPE_DEFLECTION, - self.dict_of_deflection, - ) - - self._attr_is_on = self.dict_of_deflection["Enable"] == "1" - self._is_available = True - - self._attributes["type"] = self.dict_of_deflection["Type"] - self._attributes["number"] = self.dict_of_deflection["Number"] - self._attributes["deflection_to_number"] = self.dict_of_deflection[ - "DeflectionToNumber" - ] - # Return mode sample: "eImmediately" - self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] - self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] - self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] - - async def _async_switch_on_off_executor(self, turn_on: bool) -> None: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle deflection switch.""" - await self._avm_wrapper.async_set_deflection_enable(self.id, turn_on) + await self.coordinator.async_set_deflection_enable(self.deflection_id, turn_on) class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 33b4b8d5152..7922224e195 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -159,6 +159,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( translation_key="comfort_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_comfort_temperature, native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] ), @@ -167,6 +168,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( translation_key="eco_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_eco_temperature, native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] ), @@ -175,6 +177,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( translation_key="nextchange_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] ), @@ -182,18 +185,21 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( key="nextchange_time", translation_key="nextchange_time", device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_time, native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), ), FritzSensorEntityDescription( key="nextchange_preset", translation_key="nextchange_preset", + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=value_nextchange_preset, ), FritzSensorEntityDescription( key="scheduled_preset", translation_key="scheduled_preset", + entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, native_value=value_scheduled_preset, ), diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9e0659048b0..6489b150e79 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==20230411.1"] + "requirements": ["home-assistant-frontend==20230503.1"] } diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 4a884063f83..62f2623d05e 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -8,7 +8,7 @@ from afsapi import AFSAPI, ConnectionError as FSConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await afsapi.get_power() except FSConnectionError as exception: - raise PlatformNotReady from exception + raise ConfigEntryNotReady from exception hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0ccc61e99c1..7067f882973 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Frontier Silicon Media Player integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -53,6 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _name: str _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.""" @@ -192,6 +194,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"name": self._name} ) + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._webfsapi_url = config[CONF_WEBFSAPI_URL] + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_device_config() + async def async_step_device_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -220,6 +232,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_PIN: user_input[CONF_PIN]}, + ) + 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() await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a7c3f3e439c..193ca7123f4 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -24,7 +24,11 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "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%]" } }, "issues": { diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 0b1e040c79b..693959561d2 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.4.0"] + "requirements": ["ha-av==10.0.0", "pillow==9.5.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 73d876b354f..c1ebc948b94 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -270,7 +270,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return self._state = True await self._async_operate(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn hygrostat off.""" @@ -279,7 +279,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._state = False if self._is_device_active: await self._async_device_turn_off() - await self.async_update_ha_state() + self.async_write_ha_state() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -288,12 +288,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if self._is_away and self._away_fixed: self._saved_target_humidity = humidity - await self.async_update_ha_state() + self.async_write_ha_state() return self._target_humidity = humidity await self._async_operate() - await self.async_update_ha_state() + self.async_write_ha_state() @property def min_humidity(self): @@ -329,7 +329,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_update_humidity(new_state.state) await self._async_operate() - await self.async_update_ha_state() + self.async_write_ha_state() async def _async_sensor_not_responding(self, now=None): """Handle sensor stale event.""" @@ -471,4 +471,4 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) await self._async_operate(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 391dd874426..5387c043fc3 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -74,7 +74,7 @@ "name": "[%key:component::sensor::entity_component::pm10::name%]" }, "pm10_index": { - "name": "Particulate matter 10 μm index", + "name": "PM10 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -88,7 +88,7 @@ "name": "[%key:component::sensor::entity_component::pm25::name%]" }, "pm25_index": { - "name": "Particulate matter 2.5 μm index", + "name": "PM2.5 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad5..db5b189d5ea 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ ATTR_USERNAME = "username" DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ class GitterSensor(SensorEntity): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a913..04e133248a6 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6e..01e498a8897 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a206..8b836fba3ea 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ async def async_setup_entry( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, - ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 45d02dcd2e3..f40d2253614 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.30"] + "requirements": ["goodwe==0.2.31"] } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac22..3752574f31f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.percentage import ( from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 93699321eda..7a9ca70bf14 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -150,6 +150,14 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): "url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", } + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + language_code = self.entry.options.get( + CONF_LANGUAGE_CODE, default_language_code(self.hass) + ) + return [language_code] + async def async_process( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index c30ea1c0a65..8023b9222a0 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -43,10 +43,10 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity): """Get the vacation data.""" service = await self.auth.get_resource() settings: HttpRequest = service.users().settings().getVacation(userId="me") - data = await self.hass.async_add_executor_job(settings.execute) + data: dict = await self.hass.async_add_executor_job(settings.execute) - if data["enableAutoReply"]: - value = datetime.fromtimestamp(int(data["endTime"]) / 1000, tz=timezone.utc) + if data["enableAutoReply"] and (end := data.get("endTime")): + value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) else: value = None self._attr_native_value = value diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index 6ba831442d6..d7364e834a3 100644 --- a/homeassistant/components/google_maps/manifest.json +++ b/homeassistant/components/google_maps/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_maps", "iot_class": "cloud_polling", "loggers": ["locationsharinglib"], - "requirements": ["locationsharinglib==4.1.5"] + "requirements": ["locationsharinglib==5.0.1"] } diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 77e1d0f7d33..9fac4d01926 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -610,14 +610,6 @@ class GTFSDepartureSensor(SensorEntity): self._include_tomorrow, ) - # Define the state as a UTC timestamp with ISO 8601 format - if not self._departure: - self._state = None - else: - self._state = self._departure["departure_time"].replace( - tzinfo=dt_util.UTC - ) - # Fetch trip and route details once, unless updated if not self._departure: self._trip = None @@ -648,6 +640,19 @@ class GTFSDepartureSensor(SensorEntity): ) self._agency = False + # Define the state as a UTC timestamp with ISO 8601 format + if not self._departure: + self._state = None + else: + if self._agency: + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.get_time_zone(self._agency.agency_timezone) + ) + else: + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.UTC + ) + # Assign attributes, icon and name self.update_attributes() diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py index 801bc9b923a..6b852291323 100644 --- a/homeassistant/components/hardware/models.py +++ b/homeassistant/components/hardware/models.py @@ -7,7 +7,7 @@ from typing import Protocol from homeassistant.core import HomeAssistant, callback -@dataclass +@dataclass(slots=True) class BoardInfo: """Board info type.""" @@ -17,7 +17,7 @@ class BoardInfo: revision: str | None -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class USBInfo: """USB info type.""" @@ -28,7 +28,7 @@ class USBInfo: description: str | None -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class HardwareInfo: """Hardware info type.""" diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 66b90edc893..918c96c5643 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -20,7 +20,7 @@ from .hardware import async_process_hardware_platforms from .models import HardwareProtocol -@dataclass +@dataclass(slots=True) class SystemStatus: """System status.""" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e6ff9888b15..78d974fe9cf 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import logging import os from typing import Any, NamedTuple @@ -29,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( DOMAIN as HASS_DOMAIN, + HassJob, HomeAssistant, ServiceCall, callback, @@ -72,6 +73,7 @@ from .const import ( DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, SupervisorEntityModel, ) @@ -83,9 +85,12 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_yellow_settings, async_install_addon, + async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_yellow_settings, async_start_addon, async_stop_addon, async_uninstall_addon, @@ -126,7 +131,6 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -492,7 +496,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: DOMAIN, service, async_service_handler, schema=settings.schema ) - async def update_info_data(now): + async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" try: @@ -516,11 +520,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: _LOGGER.warning("Can't read Supervisor data: %s", err) async_track_point_in_utc_time( - hass, update_info_data, utcnow() + HASSIO_UPDATE_INTERVAL + hass, + HassJob(update_info_data, cancel_on_shutdown=True), + utcnow() + HASSIO_UPDATE_INTERVAL, ) # Fetch data - await update_info_data(None) + await update_info_data() async def async_handle_core_service(call: ServiceCall) -> None: """Service handler for handling core services.""" @@ -611,7 +617,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) # Start listening for problems with supervisor and making issues - hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) await issues.setup() return True diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 16845e6f76c..e2cd1bae270 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -29,7 +29,7 @@ ADDON_ENTITY_DESCRIPTIONS = ( device_class=BinarySensorDeviceClass.RUNNING, entity_registry_enabled_default=False, key=ATTR_STATE, - name="Running", + translation_key="state", target=ATTR_STARTED, ), ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index cc9c58a3d27..1dfd5ce53cd 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -16,10 +16,12 @@ ATTR_FOLDERS = "folders" ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" ATTR_INPUT = "input" +ATTR_ISSUES = "issues" ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" ATTR_RESULT = "result" +ATTR_SUGGESTIONS = "suggestions" ATTR_SUPPORTED = "supported" ATTR_TIMEOUT = "timeout" ATTR_TITLE = "title" @@ -49,6 +51,8 @@ EVENT_SUPERVISOR_EVENT = "supervisor_event" EVENT_SUPERVISOR_UPDATE = "supervisor_update" EVENT_HEALTH_CHANGED = "health_changed" EVENT_SUPPORTED_CHANGED = "supported_changed" +EVENT_ISSUE_CHANGED = "issue_changed" +EVENT_ISSUE_REMOVED = "issue_removed" UPDATE_KEY_SUPERVISOR = "supervisor" @@ -69,6 +73,9 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" +DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" + +PLACEHOLDER_KEY_REFERENCE = "reference" class SupervisorEntityModel(str, Enum): diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 29cb53de70f..2a5ce2485d1 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -22,13 +22,14 @@ from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class HassioServiceInfo(BaseServiceInfo): """Prepared info from hassio entries.""" config: dict[str, Any] name: str slug: str + uuid: str @callback @@ -93,6 +94,7 @@ class HassIODiscovery(HomeAssistantView): service: str = data[ATTR_SERVICE] config_data: dict[str, Any] = data[ATTR_CONFIG] slug: str = data[ATTR_ADDON] + uuid: str = data[ATTR_UUID] # Read additional Add-on info try: @@ -109,7 +111,7 @@ class HassIODiscovery(HomeAssistantView): self.hass, service, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=config_data, name=name, slug=slug), + data=HassioServiceInfo(config=config_data, name=name, slug=slug, uuid=uuid), ) async def async_process_del(self, data): @@ -128,6 +130,6 @@ class HassIODiscovery(HomeAssistantView): # Use config flow for entry in self.hass.config_entries.async_entries(service): - if entry.source != config_entries.SOURCE_HASSIO: + if entry.source != config_entries.SOURCE_HASSIO or entry.unique_id != uuid: continue await self.hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d7af26851d0..e4a0dd0f77e 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import logging import os +from typing import Any import aiohttp @@ -249,6 +250,49 @@ async def async_update_core( ) +@bind_hass +@_api_bool +async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool: + """Apply a suggestion from supervisor's resolution center. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/resolution/suggestion/{suggestion_uuid}" + return await hassio.send_command(command, timeout=None) + + +@api_data +async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Yellow.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/yellow", method="get") + + +@api_data +async def async_set_yellow_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Yellow. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/yellow", method="post", payload=settings + ) + + +@api_data +async def async_reboot_host(hass: HomeAssistant) -> dict: + """Reboot the host. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/host/reboot", method="post", timeout=60) + + class HassIO: """Small API wrapper for Hass.io.""" @@ -416,6 +460,16 @@ class HassIO: """ return self.send_command("/resolution/info", method="get") + @api_data + def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]: + """Return suggestions for issue from Supervisor resolution center. + + This method returns a coroutine. + """ + return self.send_command( + f"/resolution/issue/{issue_id}/suggestions", method="get" + ) + @_api_bool async def update_hass_api(self, http_config, refresh_token): """Update Home Assistant API data on Hass.io.""" @@ -454,6 +508,14 @@ class HassIO: "/supervisor/options", payload={"diagnostics": diagnostics} ) + @_api_bool + def apply_suggestion(self, suggestion_uuid: str): + """Apply a suggestion from supervisor's resolution center. + + This method returns a coroutine. + """ + return self.send_command(f"/resolution/suggestion/{suggestion_uuid}") + async def send_command( self, command, diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index fecf05f74b4..2480353c2d3 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -35,7 +35,6 @@ _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 -# pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" r"|backups/.+/full" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index a0d51c4806d..ac6af7f3489 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,7 +1,12 @@ """Supervisor events monitor.""" from __future__ import annotations -from typing import Any +import asyncio +from dataclasses import dataclass, field +import logging +from typing import Any, TypedDict + +from typing_extensions import NotRequired from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,6 +19,8 @@ from homeassistant.helpers.issue_registry import ( from .const import ( ATTR_DATA, ATTR_HEALTHY, + ATTR_ISSUES, + ATTR_SUGGESTIONS, ATTR_SUPPORTED, ATTR_UNHEALTHY, ATTR_UNHEALTHY_REASONS, @@ -23,19 +30,26 @@ from .const import ( ATTR_WS_EVENT, DOMAIN, EVENT_HEALTH_CHANGED, + EVENT_ISSUE_CHANGED, + EVENT_ISSUE_REMOVED, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, ) -from .handler import HassIO +from .handler import HassIO, HassioAPIError +ISSUE_KEY_UNHEALTHY = "unhealthy" +ISSUE_KEY_UNSUPPORTED = "unsupported" ISSUE_ID_UNHEALTHY = "unhealthy_system" ISSUE_ID_UNSUPPORTED = "unsupported_system" INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" +PLACEHOLDER_KEY_REASON = "reason" + UNSUPPORTED_REASONS = { "apparmor", "connectivity_check", @@ -69,6 +83,88 @@ UNHEALTHY_REASONS = { "untrusted", } +# Keys (type + context) of issues that when found should be made into a repair +ISSUE_KEYS_FOR_REPAIRS = { + "issue_system_multiple_data_disks", + "issue_system_reboot_required", +} + +_LOGGER = logging.getLogger(__name__) + + +class SuggestionDataType(TypedDict): + """Suggestion dictionary as received from supervisor.""" + + uuid: str + type: str + context: str + reference: str | None + + +@dataclass(slots=True, frozen=True) +class Suggestion: + """Suggestion from Supervisor which resolves an issue.""" + + uuid: str + type_: str + context: str + reference: str | None = None + + @property + def key(self) -> str: + """Get key for suggestion (combination of context and 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"], + reference=data["reference"], + ) + + +class IssueDataType(TypedDict): + """Issue dictionary as received from supervisor.""" + + uuid: str + type: str + context: str + reference: str | None + suggestions: NotRequired[list[SuggestionDataType]] + + +@dataclass(slots=True, frozen=True) +class Issue: + """Issue from Supervisor.""" + + uuid: str + type_: str + context: str + 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_}" + + @classmethod + def from_dict(cls, data: IssueDataType) -> Issue: + """Convert from dictionary representation.""" + suggestions: list[SuggestionDataType] = data.get("suggestions", []) + return cls( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + suggestions=[ + Suggestion.from_dict(suggestion) for suggestion in suggestions + ], + ) + class SupervisorIssues: """Create issues from supervisor events.""" @@ -79,6 +175,7 @@ class SupervisorIssues: self._client = client self._unsupported_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set() + self._issues: dict[str, Issue] = {} @property def unhealthy_reasons(self) -> set[str]: @@ -87,14 +184,14 @@ class SupervisorIssues: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete issues as necessary.""" + """Set unhealthy reasons. Create or delete repairs as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: - translation_key = f"unhealthy_{unhealthy}" + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" translation_placeholders = None else: - translation_key = "unhealthy" - translation_placeholders = {"reason": unhealthy} + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} async_create_issue( self._hass, @@ -119,14 +216,14 @@ class SupervisorIssues: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete issues as necessary.""" + """Set unsupported reasons. Create or delete repairs as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: - translation_key = f"unsupported_{unsupported}" + translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" translation_placeholders = None else: - translation_key = "unsupported" - translation_placeholders = {"reason": unsupported} + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} async_create_issue( self._hass, @@ -144,6 +241,61 @@ class SupervisorIssues: self._unsupported_reasons = reasons + 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: + placeholders: dict[str, str] | None = None + if issue.reference: + placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} + async_create_issue( + self._hass, + DOMAIN, + issue.uuid, + is_fixable=bool(issue.suggestions), + severity=IssueSeverity.WARNING, + translation_key=issue.key, + translation_placeholders=placeholders, + ) + + self._issues[issue.uuid] = issue + + 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 + ], + ) + ) + except HassioAPIError: + _LOGGER.error( + "Could not get suggestions for supervisor issue %s, skipping it", + data["uuid"], + ) + + def remove_issue(self, issue: Issue) -> None: + """Remove an issue from the list. Delete a repair if necessary.""" + if issue.uuid not in self._issues: + return + + if issue.key in ISSUE_KEYS_FOR_REPAIRS: + async_delete_issue(self._hass, DOMAIN, issue.uuid) + + del self._issues[issue.uuid] + + def get_issue(self, issue_id: str) -> Issue | None: + """Get issue from key.""" + return self._issues.get(issue_id) + async def setup(self) -> None: """Create supervisor events listener.""" await self.update() @@ -153,11 +305,22 @@ class SupervisorIssues: ) async def update(self) -> None: - """Update issuess from Supervisor resolution center.""" + """Update issues from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) + # Remove any cached issues that weren't returned + for issue_id in set(self._issues.keys()) - { + issue["uuid"] for issue in data[ATTR_ISSUES] + }: + self.remove_issue(self._issues[issue_id]) + + # Add/update any issues that came back + await asyncio.gather( + *[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]] + ) + @callback def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: """Create issues from supervisor events.""" @@ -183,3 +346,9 @@ class SupervisorIssues: if event[ATTR_DATA][ATTR_SUPPORTED] else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) ) + + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: + self.add_issue(Issue.from_dict(event[ATTR_DATA])) + + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: + self.remove_issue(Issue.from_dict(event[ATTR_DATA])) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py new file mode 100644 index 00000000000..50a9b087a7c --- /dev/null +++ b/homeassistant/components/hassio/repairs.py @@ -0,0 +1,122 @@ +"""Repairs implementation for supervisor integration.""" + +from collections.abc import Callable +from types import MethodType +from typing import Any + +import voluptuous as vol + +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 .handler import HassioAPIError, async_apply_suggestion +from .issues import Issue, Suggestion, SupervisorIssues + +SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} + + +class SupervisorIssueRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + _data: dict[str, Any] | None = None + _issue: Issue | None = None + + def __init__(self, issue_id: str) -> None: + """Initialize repair flow.""" + self._issue_id = issue_id + super().__init__() + + @property + def issue(self) -> Issue | None: + """Get associated issue.""" + if not self._issue: + supervisor_issues: SupervisorIssues = self.hass.data[ + DATA_KEY_SUPERVISOR_ISSUES + ] + self._issue = supervisor_issues.get_issue(self._issue_id) + + return self._issue + + @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 + ) + + def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: + """Return form for suggestion.""" + return self.async_show_form( + step_id=suggestion.key, + data_schema=vol.Schema({}), + description_placeholders=self.description_placeholders, + last_step=True, + ) + + async def async_step_init(self, _: None = None) -> FlowResult: + """Handle the first step of a fix flow.""" + # Out of sync with supervisor, issue is resolved or not fixable. Remove it + if not self.issue or not self.issue.suggestions: + return self.async_create_entry(data={}) + + # All suggestions have the same logic: Apply them in supervisor, + # optionally with a confirmation step. Generating the required handler for each + # allows for shared logic but screens can still be translated per step id. + for suggestion in self.issue.suggestions: + setattr( + self, + f"async_step_{suggestion.key}", + MethodType(self._async_step(suggestion), self), + ) + + if len(self.issue.suggestions) > 1: + return self.async_show_menu( + step_id="fix_menu", + menu_options=[suggestion.key for suggestion in self.issue.suggestions], + description_placeholders=self.description_placeholders, + ) + + # Always show a form for one suggestion to explain to user what's happening + return self._async_form_for_suggestion(self.issue.suggestions[0]) + + async def _async_step_apply_suggestion( + self, suggestion: Suggestion, confirmed: bool = False + ) -> FlowResult: + """Handle applying a suggestion as a flow step. Optionally request confirmation.""" + if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: + return self._async_form_for_suggestion(suggestion) + + try: + await async_apply_suggestion(self.hass, suggestion.uuid) + except HassioAPIError: + return self.async_abort(reason="apply_suggestion_fail") + + return self.async_create_entry(data={}) + + @staticmethod + def _async_step(suggestion: Suggestion) -> Callable: + """Generate a step handler for a suggestion.""" + + async def _async_step( + self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow step for a suggestion.""" + # pylint: disable-next=protected-access + return await self._async_step_apply_suggestion( + suggestion, confirmed=user_input is not None + ) + + return _async_step + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index b9a97adcbc2..b49433961e3 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -36,12 +36,12 @@ COMMON_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_VERSION, - name="Version", + translation_key="version", ), SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_VERSION_LATEST, - name="Newest version", + translation_key="version_latest", ), ) @@ -49,7 +49,7 @@ STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, - name="CPU percent", + translation_key="cpu_percent", icon="mdi:cpu-64-bit", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +57,7 @@ STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_MEMORY_PERCENT, - name="Memory percent", + translation_key="memory_percent", icon="mdi:memory", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -73,19 +73,19 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="agent_version", - name="OS Agent version", + translation_key="agent_version", entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( entity_registry_enabled_default=False, key="apparmor_version", - name="Apparmor version", + translation_key="apparmor_version", entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( entity_registry_enabled_default=False, key="disk_total", - name="Disk total", + translation_key="disk_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -93,7 +93,7 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="disk_used", - name="Disk used", + translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -101,7 +101,7 @@ HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key="disk_free", - name="Disk free", + translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 7cda053f43a..078aac39a5b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,32 @@ } }, "issues": { + "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." + } + }, + "abort": { + "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + } + } + }, + "issue_system_reboot_required": { + "title": "Reboot required", + "fix_flow": { + "step": { + "system_execute_reboot": { + "description": "Settings were changed which require a system reboot to take effect.\n\nThis fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period." + } + }, + "abort": { + "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + } + } + }, "unhealthy": { "title": "Unhealthy system - {reason}", "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." @@ -125,5 +151,21 @@ "title": "Unsupported system - Systemd-Resolved issues", "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." } + }, + "entity": { + "binary_sensor": { + "state": { "name": "Running" } + }, + "sensor": { + "agent_version": { "name": "OS Agent version" }, + "apparmor_version": { "name": "Apparmor version" }, + "cpu_percent": { "name": "CPU percent" }, + "disk_free": { "name": "Disk free" }, + "disk_total": { "name": "Disk total" }, + "disk_used": { "name": "Disk used" }, + "memory_percent": { "name": "Memory percent" }, + "version": { "name": "Version" }, + "version_latest": { "name": "Newest version" } + } } } diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 36f2f8945c0..f5b97a7fb13 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations from datetime import datetime as dt, timedelta from http import HTTPStatus -import logging -import time from typing import cast from aiohttp import web @@ -12,39 +10,28 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView -from homeassistant.components.recorder import ( - DOMAIN as RECORDER_DOMAIN, - get_instance, - history, -) -from homeassistant.components.recorder.filters import ( - Filters, - extract_include_exclude_filter_conf, - merge_include_exclude_filters, - sqlalchemy_filter_from_include_exclude_conf, -) +from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.core import HomeAssistant, valid_entity_id import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import ( - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, - convert_include_exclude_filter, -) +from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig - -_LOGGER = logging.getLogger(__name__) CONF_ORDER = "use_include_order" +_ONE_DAY = timedelta(days=1) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( + cv.deprecated(CONF_INCLUDE), + cv.deprecated(CONF_EXCLUDE), cv.deprecated(CONF_ORDER), INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( {vol.Optional(CONF_ORDER, default=False): cv.boolean} @@ -57,23 +44,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the history hooks.""" - conf = config.get(DOMAIN, {}) - recorder_conf = config.get(RECORDER_DOMAIN, {}) - history_conf = config.get(DOMAIN, {}) - recorder_filter = extract_include_exclude_filter_conf(recorder_conf) - logbook_filter = extract_include_exclude_filter_conf(history_conf) - merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) - - possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) - - sqlalchemy_filter = None - entity_filter = None - if not possible_merged_entities_filter.empty_filter: - sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(conf) - entity_filter = possible_merged_entities_filter - - hass.data[DOMAIN] = HistoryConfig(sqlalchemy_filter, entity_filter) - hass.http.register_view(HistoryPeriodView(sqlalchemy_filter)) + hass.http.register_view(HistoryPeriodView()) frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box") websocket_api.async_setup(hass) return True @@ -86,50 +57,54 @@ class HistoryPeriodView(HomeAssistantView): name = "api:history:view-period" extra_urls = ["/api/history/period/{datetime}"] - def __init__(self, filters: Filters | None) -> None: - """Initialize the history period view.""" - self.filters = filters - async def get( self, request: web.Request, datetime: str | None = None ) -> web.Response: """Return history over a period of time.""" datetime_ = None + query = request.query + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) - now = dt_util.utcnow() + if not (entity_ids_str := query.get("filter_entity_id")) or not ( + entity_ids := entity_ids_str.strip().lower().split(",") + ): + return self.json_message( + "filter_entity_id is missing", HTTPStatus.BAD_REQUEST + ) - one_day = timedelta(days=1) + hass = request.app["hass"] + + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + return self.json_message( + "Invalid filter_entity_id", HTTPStatus.BAD_REQUEST + ) + + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) else: - start_time = now - one_day + start_time = now - _ONE_DAY if start_time > now: return self.json([]) - if end_time_str := request.query.get("end_time"): + if end_time_str := query.get("end_time"): if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) else: - end_time = start_time + one_day - entity_ids_str = request.query.get("filter_entity_id") - entity_ids = None - if entity_ids_str: - entity_ids = entity_ids_str.lower().split(",") - include_start_time_state = "skip_initial_state" not in request.query - significant_changes_only = ( - request.query.get("significant_changes_only", "1") != "0" - ) + end_time = start_time + _ONE_DAY + + include_start_time_state = "skip_initial_state" not in query + significant_changes_only = query.get("significant_changes_only", "1") != "0" minimal_response = "minimal_response" in request.query no_attributes = "no_attributes" in request.query - hass = request.app["hass"] - if ( not include_start_time_state and entity_ids @@ -159,33 +134,27 @@ class HistoryPeriodView(HomeAssistantView): hass: HomeAssistant, start_time: dt, end_time: dt, - entity_ids: list[str] | None, + entity_ids: list[str], include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, no_attributes: bool, ) -> web.Response: """Fetch significant stats from the database as json.""" - timer_start = time.perf_counter() - with session_scope(hass=hass, read_only=True) as session: - states = history.get_significant_states_with_session( - hass, - session, - start_time, - end_time, - entity_ids, - self.filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, + return self.json( + list( + history.get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + None, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + ).values() + ) ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug( - "Extracted %d states in %fs", sum(map(len, states.values())), elapsed - ) - - return self.json(list(states.values())) diff --git a/homeassistant/components/history/models.py b/homeassistant/components/history/models.py deleted file mode 100644 index 3998d9f7e00..00000000000 --- a/homeassistant/components/history/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models for the history integration.""" -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.recorder.filters import Filters -from homeassistant.helpers.entityfilter import EntityFilter - - -@dataclass -class HistoryConfig: - """Configuration for the history integration.""" - - sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index a761021de55..93a5d272965 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history -from homeassistant.components.recorder.filters import Filters from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import ( @@ -20,7 +19,6 @@ from homeassistant.const import ( COMPRESSED_STATE_LAST_CHANGED, COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, - EVENT_STATE_CHANGED, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -29,8 +27,8 @@ from homeassistant.core import ( State, callback, is_callback, + valid_entity_id, ) -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, @@ -38,14 +36,13 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import DOMAIN, EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES +from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after -from .models import HistoryConfig _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class HistoryLiveStream: """Track a history live stream.""" @@ -69,7 +66,6 @@ def _ws_get_significant_states( start_time: dt, end_time: dt | None, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -84,7 +80,7 @@ def _ws_get_significant_states( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -100,7 +96,7 @@ def _ws_get_significant_states( vol.Required("type"): "history/history_during_period", vol.Required("start_time"): str, vol.Optional("end_time"): str, - vol.Optional("entity_ids"): [str], + vol.Required("entity_ids"): [str], vol.Optional("include_start_time_state", default=True): bool, vol.Optional("significant_changes_only", default=True): bool, vol.Optional("minimal_response", default=False): bool, @@ -134,7 +130,12 @@ async def ws_get_history_during_period( connection.send_result(msg["id"], {}) return - entity_ids = msg.get("entity_ids") + entity_ids: list[str] = msg["entity_ids"] + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") + return + include_start_time_state = msg["include_start_time_state"] no_attributes = msg["no_attributes"] @@ -150,7 +151,6 @@ async def ws_get_history_during_period( significant_changes_only = msg["significant_changes_only"] minimal_response = msg["minimal_response"] - history_config: HistoryConfig = hass.data[DOMAIN] connection.send_message( await get_instance(hass).async_add_executor_job( @@ -160,7 +160,6 @@ async def ws_get_history_during_period( start_time, end_time, entity_ids, - history_config.sqlalchemy_filter, include_start_time_state, significant_changes_only, minimal_response, @@ -214,7 +213,6 @@ def _generate_historical_response( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -229,7 +227,7 @@ def _generate_historical_response( start_time, end_time, entity_ids, - filters, + None, include_start_time_state, significant_changes_only, minimal_response, @@ -270,7 +268,6 @@ async def _async_send_historical_states( start_time: dt, end_time: dt, entity_ids: list[str] | None, - filters: Filters | None, include_start_time_state: bool, significant_changes_only: bool, minimal_response: bool, @@ -286,7 +283,6 @@ async def _async_send_historical_states( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -365,8 +361,7 @@ def _async_subscribe_events( hass: HomeAssistant, subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], - entities_filter: EntityFilter | None, - entity_ids: list[str] | None, + entity_ids: list[str], significant_changes_only: bool, minimal_response: bool, ) -> None: @@ -386,7 +381,7 @@ def _async_subscribe_events( return assert isinstance(new_state, State) assert isinstance(old_state, State) - if (entities_filter and not entities_filter(new_state.entity_id)) or ( + if ( (significant_changes_only or minimal_response) and new_state.state == old_state.state and new_state.domain not in history.SIGNIFICANT_DOMAINS @@ -394,21 +389,8 @@ def _async_subscribe_events( return target(event) - if entity_ids: - subscriptions.append( - async_track_state_change_event( - hass, entity_ids, _forward_state_events_filtered - ) - ) - return - - # We want the firehose subscriptions.append( - hass.bus.async_listen( - EVENT_STATE_CHANGED, - _forward_state_events_filtered, - run_immediately=True, - ) + async_track_state_change_event(hass, entity_ids, _forward_state_events_filtered) ) @@ -417,7 +399,7 @@ def _async_subscribe_events( vol.Required("type"): "history/stream", vol.Required("start_time"): str, vol.Optional("end_time"): str, - vol.Optional("entity_ids"): [str], + vol.Required("entity_ids"): [str], vol.Optional("include_start_time_state", default=True): bool, vol.Optional("significant_changes_only", default=True): bool, vol.Optional("minimal_response", default=False): bool, @@ -431,15 +413,7 @@ async def ws_stream( """Handle history stream websocket command.""" start_time_str = msg["start_time"] msg_id: int = msg["id"] - entity_ids: list[str] | None = msg.get("entity_ids") utc_now = dt_util.utcnow() - filters: Filters | None = None - entities_filter: EntityFilter | None = None - - if not entity_ids: - history_config: HistoryConfig = hass.data[DOMAIN] - filters = history_config.sqlalchemy_filter - entities_filter = history_config.entity_filter if start_time := dt_util.parse_datetime(start_time_str): start_time = dt_util.as_utc(start_time) @@ -459,7 +433,12 @@ async def ws_stream( connection.send_error(msg_id, "invalid_end_time", "Invalid end_time") return - entity_ids = msg.get("entity_ids") + entity_ids: list[str] = msg["entity_ids"] + for entity_id in entity_ids: + if not hass.states.get(entity_id) and not valid_entity_id(entity_id): + connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") + return + include_start_time_state = msg["include_start_time_state"] significant_changes_only = msg["significant_changes_only"] no_attributes = msg["no_attributes"] @@ -485,7 +464,6 @@ async def ws_stream( start_time, end_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -535,7 +513,6 @@ async def ws_stream( hass, subscriptions, _queue_or_cancel, - entities_filter, entity_ids, significant_changes_only=significant_changes_only, minimal_response=minimal_response, @@ -551,7 +528,6 @@ async def ws_stream( start_time, subscriptions_setup_complete_time, entity_ids, - filters, include_start_time_state, significant_changes_only, minimal_response, @@ -593,7 +569,6 @@ async def ws_stream( last_event_time or start_time, subscriptions_setup_complete_time, entity_ids, - filters, False, # We don't want the start time state again significant_changes_only, minimal_response, diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index d9b331d82bb..af27766f514 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -78,7 +78,7 @@ class HistoryStats: utc_now = dt_util.utcnow() now_timestamp = floored_timestamp(utc_now) - if current_period_start > utc_now: + if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] self._previous_run_before_start = True @@ -122,7 +122,9 @@ class HistoryStats: # Don't compute anything as the value cannot have changed return self._state else: - await self._async_history_from_db(current_period_start, current_period_end) + await self._async_history_from_db( + current_period_start_timestamp, current_period_end_timestamp + ) self._previous_run_before_start = False seconds_matched, match_count = self._async_compute_seconds_and_changes( @@ -135,15 +137,15 @@ class HistoryStats: async def _async_history_from_db( self, - current_period_start: datetime.datetime, - current_period_end: datetime.datetime, + current_period_start_timestamp: float, + current_period_end_timestamp: float, ) -> None: """Update history data for the current period from the database.""" instance = get_instance(self.hass) states = await instance.async_add_executor_job( self._state_changes_during_period, - current_period_start, - current_period_end, + current_period_start_timestamp, + current_period_end_timestamp, ) self._history_current_period = [ HistoryState(state.state, state.last_changed.timestamp()) @@ -151,8 +153,11 @@ class HistoryStats: ] def _state_changes_during_period( - self, start: datetime.datetime, end: datetime.datetime + self, start_ts: float, end_ts: float ) -> list[State]: + """Return state changes during a period.""" + start = dt_util.utc_from_timestamp(start_ts) + end = dt_util.utc_from_timestamp(end_ts) return history.state_changes_during_period( self.hass, start, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 91dd742e802..987a4317ba8 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -33,10 +33,12 @@ from homeassistant.helpers.service import ( from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" @@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 00000000000..f3bc95dd1ee --- /dev/null +++ b/homeassistant/components/homeassistant/const.py @@ -0,0 +1,6 @@ +"""Constants for the Homeassistant integration.""" +import homeassistant.core as ha + +DOMAIN = ha.DOMAIN + +DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py new file mode 100644 index 00000000000..07f14e7ce8c --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,537 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from itertools import chain +from typing import Any, TypedDict + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant", "conversation") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + +DEFAULT_EXPOSED_ASSISTANT = { + "conversation": True, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +@dataclasses.dataclass(frozen=True) +class ExposedEntity: + """An exposed entity without a unique_id.""" + + assistants: dict[str, dict[str, Any]] + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "assistants": self.assistants, + } + + +class SerializedExposedEntities(TypedDict): + """Serialized exposed entities storage storage collection.""" + + assistants: dict[str, dict[str, Any]] + exposed_entities: dict[str, dict[str, Any]] + + +class ExposedEntities: + """Control assistant settings. + + Settings for entities without a unique_id are stored in the store. + Settings for entities with a unique_id are stored in the entity registry. + """ + + _assistants: dict[str, AssistantPreferences] + entities: dict[str, ExposedEntity] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[SerializedExposedEntities] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + websocket_api.async_register_command(self._hass, ws_list_exposed_entities) + await self._async_load_data() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_set_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + return self._async_set_legacy_assistant_option( + assistant, entity_id, key, value + ) + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get(key) == value: + return + + assistant_options = assistant_options | {key: value} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + def _async_set_legacy_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + if ( + (exposed_entity := self.entities.get(entity_id)) + and (assistant_options := exposed_entity.assistants.get(assistant, {})) + and assistant_options.get(key) == value + ): + return + + if exposed_entity: + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, key, value + ) + else: + new_exposed_entity = self._new_exposed_entity(assistant, key, value) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False) + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + options: Mapping | None + for entity_id, exposed_entity in self.entities.items(): + if options := exposed_entity.assistants.get(assistant): + result[entity_id] = options + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]: + """Get assistant expose settings for an entity.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + assistant_settings: Mapping + if registry_entry := entity_registry.async_get(entity_id): + assistant_settings = registry_entry.options + elif exposed_entity := self.entities.get(entity_id): + assistant_settings = exposed_entity.assistants + else: + raise HomeAssistantError("Unknown entity") + + for assistant in KNOWN_ASSISTANTS: + if options := assistant_settings.get(assistant): + result[assistant] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + return self._async_should_expose_legacy_entity(assistant, entity_id) + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _async_should_expose_legacy_entity( + self, assistant: str, entity_id: str + ) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if ( + exposed_entity := self.entities.get(entity_id) + ) and assistant in exposed_entity.assistants: + if "should_expose" in exposed_entity.assistants[assistant]: + should_expose = exposed_entity.assistants[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, None) + else: + should_expose = False + + if exposed_entity: + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, "should_expose", should_expose + ) + else: + new_exposed_entity = self._new_exposed_entity( + assistant, "should_expose", should_expose + ) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry | None + ) -> bool: + """Return True if an entity is exposed by default.""" + if registry_entry and ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + try: + device_class = get_device_class(self._hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + def _update_exposed_entity( + self, assistant: str, entity_id: str, key: str, value: Any + ) -> ExposedEntity: + """Update an exposed entity.""" + entity = self.entities[entity_id] + assistants = dict(entity.assistants) + old_settings = assistants.get(assistant, {}) + assistants[assistant] = old_settings | {key: value} + return ExposedEntity(assistants) + + def _new_exposed_entity( + self, assistant: str, key: str, value: Any + ) -> ExposedEntity: + """Create a new exposed entity.""" + return ExposedEntity( + assistants={assistant: {key: value}}, + ) + + async def _async_load_data(self) -> SerializedExposedEntities | None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + exposed_entities: dict[str, ExposedEntity] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + if data and "exposed_entities" in data: + for entity_id, preferences in data["exposed_entities"].items(): + exposed_entities[entity_id] = ExposedEntity(**preferences) + + self._assistants = assistants + self.entities = exposed_entities + + return data + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> SerializedExposedEntities: + """Return JSON-compatible date for storing to file.""" + return { + "assistants": { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + }, + "exposed_entities": { + entity_id: entity.to_json() + for entity_id, entity in self.entities.items() + }, + } + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + for entity_id in entity_ids: + for assistant in msg["assistants"]: + async_expose_entity(hass, assistant, entity_id, msg["should_expose"]) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity/list", + } +) +def ws_list_exposed_entities( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + result: dict[str, Any] = {} + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + entity_registry = er.async_get(hass) + for entity_id in chain(exposed_entities.entities, entity_registry.entities): + result[entity_id] = {} + entity_settings = async_get_entity_settings(hass, entity_id) + for assistant, settings in entity_settings.items(): + if "should_expose" not in settings: + continue + result[entity_id][assistant] = settings["should_expose"] + connection.send_result(msg["id"], {"exposed_entities": result}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_get_entity_settings( + hass: HomeAssistant, entity_id: str +) -> dict[str, Mapping[str, Any]]: + """Get assistant expose settings for an entity.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_entity_settings(entity_id) + + +@callback +def async_expose_entity( + hass: HomeAssistant, + assistant: str, + entity_id: str, + should_expose: bool, +) -> None: + """Get assistant expose settings for an entity.""" + async_set_assistant_option( + hass, assistant, entity_id, "should_expose", should_expose + ) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) + + +@callback +def async_set_assistant_option( + hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any +) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_assistant_option(assistant, entity_id, option, value) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index ffc0594baf3..8b04f845709 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -106,7 +106,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class IntegrationAlert: """Issue Registry Entry.""" diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 09cdcc1469a..3da67023abd 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,15 +1,37 @@ """Config flow for the Home Assistant Yellow integration.""" from __future__ import annotations +import logging from typing import Any +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_yellow_settings, + async_reboot_host, + async_set_yellow_settings, +) from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN, ZHA_HW_DISCOVERY_DATA +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + vol.Required("disk_led"): selector.BooleanSelector(), + vol.Required("heartbeat_led"): selector.BooleanSelector(), + vol.Required("power_led"): selector.BooleanSelector(), + } +) + class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" @@ -35,6 +57,82 @@ class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for Home Assistant Yellow.""" + _hw_settings: dict[str, bool] | None = None + + async def async_step_on_supervisor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle logic when on Supervisor host.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "hardware_settings", + "multipan_settings", + ], + ) + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with async_timeout.timeout(10): + await async_set_yellow_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return await self.async_step_confirm_reboot() + + try: + async with async_timeout.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_yellow_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) + + async def async_step_confirm_reboot( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reboot host.""" + return self.async_show_menu( + step_id="reboot_menu", + menu_options=[ + "reboot_now", + "reboot_later", + ], + ) + + async def async_step_reboot_now( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot now.""" + await async_reboot_host(self.hass) + return self.async_create_entry(data={}) + + async def async_step_reboot_later( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot later.""" + return self.async_create_entry(data={}) + + async def async_step_multipan_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle multipan settings.""" + return await super().async_step_on_supervisor(user_input) + async def _async_serial_port_settings( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 970f9d97a4c..d97b01c7c84 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,9 +11,31 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "disk_led": "Disk LED", + "heartbeat_led": "Heartbeat LED", + "power_led": "Power LED" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "main_menu": { + "menu_options": { + "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", + "multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + }, "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%]" @@ -31,6 +53,8 @@ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "write_hw_settings_error": "Failed to write hardware settings", "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" }, "progress": { diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index d5a6202ea27..2b56a056821 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -302,10 +302,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Begin setup HomeKit for %s", name) # ip_address and advertise_ip are yaml only - ip_address = conf.get( - CONF_IP_ADDRESS, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + ip_address = conf.get(CONF_IP_ADDRESS, [None]) + advertise_ip = conf.get( + CONF_ADVERTISE_IP, await network.async_get_source_ip(hass, MDNS_TARGET_IP) ) - advertise_ip = conf.get(CONF_ADVERTISE_IP) # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after # we started creating config entries for entities that @@ -597,7 +597,9 @@ class HomeKit: await self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_add_job(new_acc.run) + self.hass.async_create_task( + new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" + ) await self.async_config_changed() async def async_reset_accessories_in_bridge_mode( @@ -637,7 +639,9 @@ class HomeKit: await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) for state in new: if acc := self.add_bridge_accessory(state): - self.hass.async_add_job(acc.run) + self.hass.async_create_task( + acc.run(), f"HomeKit Bridge Accessory: {acc.entity_id}" + ) await self.async_config_changed() async def async_config_changed(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 4addbeb1e23..9c3d9e7929c 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -13,7 +13,7 @@ from __future__ import annotations from collections.abc import Generator import random -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 4517f9c5a5e..81dbf4f7e2e 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -19,6 +19,8 @@ VIDEO_CODEC_COPY = "copy" VIDEO_CODEC_LIBX264 = "libx264" AUDIO_CODEC_OPUS = "libopus" VIDEO_CODEC_H264_OMX = "h264_omx" +VIDEO_CODEC_H264_V4L2M2M = "h264_v4l2m2m" +VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] AUDIO_CODEC_COPY = "copy" # #### Attributes #### @@ -54,6 +56,7 @@ CONF_STREAM_ADDRESS = "stream_address" CONF_STREAM_SOURCE = "stream_source" CONF_SUPPORT_AUDIO = "support_audio" CONF_VIDEO_CODEC = "video_codec" +CONF_VIDEO_PROFILE_NAMES = "video_profile_names" CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" CONF_STREAM_COUNT = "stream_count" @@ -71,6 +74,7 @@ DEFAULT_MAX_WIDTH = 1920 DEFAULT_PORT = 21063 DEFAULT_CONFIG_FLOW_PORT = 21064 DEFAULT_VIDEO_CODEC = VIDEO_CODEC_LIBX264 +DEFAULT_VIDEO_PROFILE_NAMES = VIDEO_PROFILE_NAMES DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 DEFAULT_STREAM_COUNT = 3 diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 80eea60b9e8..746b097e99a 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -1,6 +1,6 @@ { "domain": "homekit", - "name": "HomeKit", + "name": "HomeKit Bridge", "after_dependencies": ["camera", "zeroconf"], "codeowners": ["@bdraco"], "config_flow": true, @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.6.0", - "fnvhash==0.1.0", + "fnv-hash-fast==0.3.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index d041f8e0551..74af388df85 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -24,7 +24,7 @@ "data": { "entities": "Entities" }, - "description": "All “{domains}” entities will be included unless specific entities are selected.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "exclude": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 9d7f2ae4c6b..3bc2b1ed6ae 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -40,6 +40,7 @@ from .const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -51,6 +52,7 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, SERV_DOORBELL, SERV_MOTION_SENSOR, SERV_SPEAKER, @@ -111,8 +113,6 @@ RESOLUTIONS = [ (1600, 1200), ] -VIDEO_PROFILE_NAMES = ["baseline", "main", "high"] - FFMPEG_WATCH_INTERVAL = timedelta(seconds=5) FFMPEG_LOGGER = "ffmpeg_logger" FFMPEG_WATCHER = "ffmpeg_watcher" @@ -128,6 +128,7 @@ CONFIG_DEFAULTS = { CONF_AUDIO_MAP: DEFAULT_AUDIO_MAP, CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, @@ -346,7 +347,7 @@ class Camera(HomeAccessory, PyhapCamera): if self.config[CONF_VIDEO_CODEC] != "copy": video_profile = ( "-profile:v " - + VIDEO_PROFILE_NAMES[ + + self.config[CONF_VIDEO_PROFILE_NAMES][ int.from_bytes(stream_config["v_profile_id"], byteorder="big") ] + " " diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index b585f38e981..33c35908cd1 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -115,20 +115,12 @@ class HumidifierDehumidifier(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=0 ) - max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) - max_humidity = round(max_humidity) - max_humidity = min(max_humidity, 100) - - min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) - min_humidity = round(min_humidity) - min_humidity = max(min_humidity, 0) - self.char_target_humidity = serv_humidifier_dehumidifier.configure_char( self._target_humidity_char_name, value=45, properties={ - PROP_MIN_VALUE: min_humidity, - PROP_MAX_VALUE: max_humidity, + PROP_MIN_VALUE: DEFAULT_MIN_HUMIDITY, + PROP_MAX_VALUE: DEFAULT_MAX_HUMIDITY, PROP_MIN_STEP: 1, }, ) @@ -219,7 +211,23 @@ class HumidifierDehumidifier(HomeAccessory): ) if self._target_humidity_char_name in char_values: + state = self.hass.states.get(self.entity_id) + max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) + max_humidity = round(max_humidity) + max_humidity = min(max_humidity, 100) + + min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity = round(min_humidity) + min_humidity = max(min_humidity, 0) + humidity = round(char_values[self._target_humidity_char_name]) + + if (humidity < min_humidity) or (humidity > max_humidity): + humidity = min(max_humidity, max(min_humidity, humidity)) + # Update the HomeKit value to the clamped humidity, so the user will get a visual feedback that they + # cannot not set to a value below/above the min/max. + self.char_target_humidity.set_value(humidity) + self.async_call_service( DOMAIN, SERVICE_SET_HUMIDITY, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 5f0838d91a9..0e3bcbfee86 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -95,6 +95,7 @@ from .const import ( TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_V4L2M2M, VIDEO_CODEC_LIBX264, ) @@ -107,7 +108,12 @@ MAX_VERSION_PART = 2**32 - 1 MAX_PORT = 65535 -VALID_VIDEO_CODECS = [VIDEO_CODEC_LIBX264, VIDEO_CODEC_H264_OMX, AUDIO_CODEC_COPY] +VALID_VIDEO_CODECS = [ + VIDEO_CODEC_LIBX264, + VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_V4L2M2M, + AUDIO_CODEC_COPY, +] VALID_AUDIO_CODECS = [AUDIO_CODEC_OPUS, VIDEO_CODEC_COPY] BASIC_INFO_SCHEMA = vol.Schema( diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db58..a741cf54920 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 8369b4acce0..1b86e36b826 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.13"] + "requirements": ["homematicip==1.0.14"] } diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 794bc2d12e0..3e3c967f972 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -6,7 +6,7 @@ "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", - "name": "[%key:common::config_flow::data::name%] (optional, used as name prefix for all devices)" + "name": "Name (optional, used as name prefix for all devices)" } }, "link": { @@ -16,7 +16,7 @@ }, "error": { "register_failed": "Failed to register, please try again.", - "invalid_sgtin_or_pin": "Invalid SGTIN or [%key:common::config_flow::data::pin%], please try again.", + "invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.", "press_the_button": "Please press the blue button.", "timeout_button": "Blue button press timeout, please try again." }, diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index bde70e6bb0c..dd33da56297 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -214,9 +214,9 @@ class HoneywellUSThermostat(ClimateEntity): return self._device.current_humidity @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" - return HW_MODE_TO_HVAC_MODE[self._device.system_mode] + return HW_MODE_TO_HVAC_MODE.get(self._device.system_mode) @property def hvac_action(self) -> HVACAction | None: @@ -343,12 +343,8 @@ class HoneywellUSThermostat(ClimateEntity): it doesn't get overwritten when away mode is switched on. """ self._away = True - try: - # Get current mode - mode = self._device.system_mode - except aiosomecomfort.SomeComfortError: - _LOGGER.error("Can not get system mode") - return + # Get current mode + mode = self._device.system_mode try: # Set permanent hold # and Set temperature @@ -367,12 +363,8 @@ class HoneywellUSThermostat(ClimateEntity): async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" - try: - # Get current mode - mode = self._device.system_mode - except aiosomecomfort.SomeComfortError: - _LOGGER.error("Can not get system mode") - return + # Get current mode + mode = self._device.system_mode # Check that we got a valid mode back if mode in HW_MODE_TO_HVAC_MODE: try: @@ -427,11 +419,6 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._data.client.login() - except aiosomecomfort.AuthError: - self._attr_available = False - await self.hass.async_create_task( - self.hass.config_entries.async_reload(self._data.entry_id) - ) except ( aiosomecomfort.SomeComfortError, ClientConnectionError, diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 50f768915ed..17c40cfc875 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -44,7 +44,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: image_dir = pathlib.Path(hass.config.path("image")) hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, "image", "image", @@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ImageStorageCollection(collection.StorageCollection): +class ImageStorageCollection(collection.DictStorageCollection): """Image collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -67,7 +67,6 @@ class ImageStorageCollection(collection.StorageCollection): """Initialize media storage collection.""" super().__init__( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), ) self.async_add_listener(self._change_listener) self.image_dir = image_dir @@ -126,11 +125,11 @@ class ImageStorageCollection(collection.StorageCollection): async def _update_data( self, - data: dict[str, Any], + item: dict[str, Any], update_data: dict[str, Any], ) -> dict[str, Any]: """Return a new updated data object.""" - return {**data, **self.UPDATE_SCHEMA(update_data)} + return {**item, **self.UPDATE_SCHEMA(update_data)} async def _change_listener( self, diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index b53fb8bb292..947c3cb67d5 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 8dd3019878f..71b09048e6f 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -3,29 +3,45 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +import ssl from typing import Any from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, + CONF_SSL_CIPHER_LIST, DEFAULT_PORT, DOMAIN, ) from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder -STEP_USER_DATA_SCHEMA = vol.Schema( +CIPHER_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=list(SSLCipherList), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SSL_CIPHER_LIST, + ) +) + +CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -36,6 +52,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, } ) +CONFIG_SCHEMA_ADVANCED = { + vol.Optional( + CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT + ): CIPHER_SELECTOR +} OPTIONS_SCHEMA = vol.Schema( { @@ -60,6 +81,11 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: errors[CONF_USERNAME] = errors[CONF_PASSWORD] = "invalid_auth" except InvalidFolder: errors[CONF_FOLDER] = "invalid_folder" + except ssl.SSLError: + # The aioimaplib library 1.0.1 does not raise an ssl.SSLError correctly, but is logged + # See https://github.com/bamthomas/aioimaplib/issues/91 + # This handler is added to be able to supply a better error message + errors["base"] = "ssl_error" except (asyncio.TimeoutError, AioImapException, ConnectionRefusedError): errors["base"] = "cannot_connect" else: @@ -77,14 +103,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import from imap_email_content integration.""" + data = CONFIG_SCHEMA( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_FOLDER: user_input[CONF_FOLDER], + } + ) + self._async_abort_entries_match( + { + key: data[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) + } + ) + title = user_input[CONF_NAME] + if await validate_input(data): + raise AbortFlow("cannot_connect") + return self.async_create_entry(title=title, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + + schema = CONFIG_SCHEMA + if self.show_advanced_options: + schema = schema.extend(CONFIG_SCHEMA_ADVANCED) + if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=schema) self._async_abort_entries_match( { @@ -98,7 +149,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(CONFIG_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: diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index 080f7bf6765..a1ca586b48b 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -8,5 +8,6 @@ CONF_SERVER: Final = "server" CONF_FOLDER: Final = "folder" CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" +CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e11cf1e0baf..666a82c73d4 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -21,8 +21,16 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import SSLCipherList, client_context -from .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN +from .const import ( + CONF_CHARSET, + CONF_FOLDER, + CONF_SEARCH, + CONF_SERVER, + CONF_SSL_CIPHER_LIST, + DOMAIN, +) from .errors import InvalidAuth, InvalidFolder _LOGGER = logging.getLogger(__name__) @@ -34,8 +42,13 @@ EVENT_IMAP = "imap_content" async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" - client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT]) + ssl_context = client_context( + ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) + ) + client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) + await client.wait_hello_from_server() + if client.protocol.state == NONAUTH: await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) if client.protocol.state not in {AUTH, SELECTED}: @@ -164,7 +177,7 @@ 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, + "text": message.text[:2048], "sender": message.sender, "subject": message.subject, "headers": message.headers, diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index aeaf7b6fe9c..39dfc6c0d48 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -1,7 +1,7 @@ { "domain": "imap", "name": "IMAP", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@jbouwh"], "config_flow": true, "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/imap", diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index d104f591c63..e50370dd9b1 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -9,7 +9,8 @@ "port": "[%key:common::config_flow::data::port%]", "charset": "Character set", "folder": "Folder", - "search": "IMAP search" + "search": "IMAP search", + "ssl_cipher_list": "SSL cipher list (Advanced)" } }, "reauth_confirm": { @@ -25,7 +26,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "The specified charset is not supported", "invalid_folder": "The selected folder is invalid", - "invalid_search": "The selected search is invalid" + "invalid_search": "The selected search is invalid", + "ssl_error": "An SSL error occurred. Change SSL cipher list and try again" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -49,5 +51,14 @@ "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", "invalid_search": "[%key:component::imap::config::error::invalid_search%]" } + }, + "selector": { + "ssl_cipher_list": { + "options": { + "python_default": "Default settings", + "modern": "Modern ciphers", + "intermediate": "Intermediate ciphers" + } + } } } diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 263f57a3a9d..1a148f4591b 100644 --- a/homeassistant/components/imap_email_content/__init__.py +++ b/homeassistant/components/imap_email_content/__init__.py @@ -1 +1,12 @@ """The imap_email_content component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up imap_email_content.""" + return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py new file mode 100644 index 00000000000..5f1c653030e --- /dev/null +++ b/homeassistant/components/imap_email_content/const.py @@ -0,0 +1,13 @@ +"""Constants for the imap email content integration.""" + +DOMAIN = "imap_email_content" + +CONF_SERVER = "server" +CONF_SENDERS = "senders" +CONF_FOLDER = "folder" + +ATTR_FROM = "from" +ATTR_BODY = "body" +ATTR_SUBJECT = "subject" + +DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json index 2e510a8c426..b7d0589b83f 100644 --- a/homeassistant/components/imap_email_content/manifest.json +++ b/homeassistant/components/imap_email_content/manifest.json @@ -2,6 +2,7 @@ "domain": "imap_email_content", "name": "IMAP Email Content", "codeowners": [], + "dependencies": ["imap"], "documentation": "https://www.home-assistant.io/integrations/imap_email_content", "iot_class": "cloud_push" } diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py new file mode 100644 index 00000000000..f19b0499040 --- /dev/null +++ b/homeassistant/components/imap_email_content/repairs.py @@ -0,0 +1,173 @@ +"""Repair flow for imap email content integration.""" + +from typing import Any + +import voluptuous as vol +import yaml + +from homeassistant import data_entry_flow +from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN + + +async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: + """Register an issue and suggest new config.""" + + name: str = config.get(CONF_NAME) or config[CONF_USERNAME] + + issue_id = ( + f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" + ) + + if CONF_VALUE_TEMPLATE in config: + template: str = config[CONF_VALUE_TEMPLATE].template + template = template.replace("subject", 'trigger.event.data["subject"]') + template = template.replace("from", 'trigger.event.data["sender"]') + template = template.replace("date", 'trigger.event.data["date"]') + template = template.replace("body", 'trigger.event.data["text"]') + else: + template = '{{ trigger.event.data["subject"] }}' + + template_sensor_config: ConfigType = { + "template": [ + { + "trigger": [ + { + "id": "custom_event", + "platform": "event", + "event_type": "imap_content", + "event_data": {"sender": config[CONF_SENDERS][0]}, + } + ], + "sensor": [ + { + "state": template, + "name": name, + } + ], + } + ] + } + + data = { + CONF_SERVER: config[CONF_SERVER], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_FOLDER: config[CONF_FOLDER], + } + data[CONF_VALUE_TEMPLATE] = template + data[CONF_NAME] = name + placeholders = {"yaml_example": yaml.dump(template_sensor_config)} + placeholders.update(data) + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migration", + translation_placeholders=placeholders, + data=data, + ) + + +class DeprecationRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, issue_id: str, config: ConfigType) -> None: + """Create flow.""" + self._name: str = config[CONF_NAME] + self._config: dict[str, Any] = config + self._issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_start() + + @callback + def _async_get_placeholders(self) -> dict[str, str] | None: + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return description_placeholders + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Wait for the user to start the config migration.""" + placeholders = self._async_get_placeholders() + if user_input is None: + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + placeholders = self._async_get_placeholders() + if user_input is not None: + user_input[CONF_NAME] = self._name + result = await self.hass.config_entries.flow.async_init( + IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecation", + translation_placeholders=placeholders, + data=self._config, + learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", + ) + return self.async_abort(reason=result["reason"]) + return self.async_create_entry( + title="", + data={}, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create flow.""" + return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index 53cb921860c..1df207e2968 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -26,18 +26,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.ssl import client_context +from .const import ( + ATTR_BODY, + ATTR_FROM, + ATTR_SUBJECT, + CONF_FOLDER, + CONF_SENDERS, + CONF_SERVER, + DEFAULT_PORT, +) +from .repairs import async_process_issue + _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" -CONF_SENDERS = "senders" -CONF_FOLDER = "folder" - -ATTR_FROM = "from" -ATTR_BODY = "body" -ATTR_SUBJECT = "subject" - -DEFAULT_PORT = 993 - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, @@ -79,6 +80,8 @@ def setup_platform( value_template, ) + hass.add_job(async_process_issue, hass, config) + if sensor.connected: add_entities([sensor], True) diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json new file mode 100644 index 00000000000..f84435971bf --- /dev/null +++ b/homeassistant/components/imap_email_content/strings.json @@ -0,0 +1,27 @@ +{ + "issues": { + "deprecation": { + "title": "The IMAP email content integration is deprecated", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." + }, + "migration": { + "title": "The IMAP email content integration needs attention", + "fix_flow": { + "step": { + "start": { + "title": "Migrate your IMAP email configuration", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." + }, + "confirm": { + "title": "Your IMAP server settings will be migrated", + "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." + } + }, + "abort": { + "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", + "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." + } + } + } + } +} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a8b221e4939..33cb4b9e576 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -65,7 +65,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputBooleanStorageCollection(collection.StorageCollection): +class InputBooleanStorageCollection(collection.DictStorageCollection): """Input boolean collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -79,10 +79,10 @@ class InputBooleanStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data @bind_hass @@ -110,7 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputBooleanStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -122,7 +121,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index f8ff9164214..8a1f0785435 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -56,7 +56,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputButtonStorageCollection(collection.StorageCollection): +class InputButtonStorageCollection(collection.DictStorageCollection): """Input button collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -70,10 +70,10 @@ class InputButtonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputButtonStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -107,7 +106,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 34ded40d583..c51c0fdd67c 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -148,7 +148,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = DateTimeStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -160,7 +159,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -204,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class DateTimeStorageCollection(collection.StorageCollection): +class DateTimeStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time)) @@ -218,10 +217,10 @@ class DateTimeStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputDatetime(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 05d4a4f8b95..061b388ace5 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = NumberStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -137,7 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -171,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class NumberStorageCollection(collection.StorageCollection): +class NumberStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_number)) @@ -185,7 +184,7 @@ class NumberStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused frontend to add initial value to all input numbers. @@ -201,10 +200,10 @@ class NumberStorageCollection(collection.StorageCollection): return data - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputNumber(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 9e4833954d6..186ab84fb81 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -156,7 +156,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: InputSelectStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -168,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -232,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputSelectStorageCollection(collection.StorageCollection): +class InputSelectStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_select)) @@ -247,11 +246,11 @@ class InputSelectStorageCollection(collection.StorageCollection): return cast(str, info[CONF_NAME]) async def _update_data( - self, data: dict[str, Any], update_data: dict[str, Any] + self, item: dict[str, Any], update_data: dict[str, Any] ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 6ebfdcd70dc..efd58e38e72 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputTextStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -137,7 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -165,7 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputTextStorageCollection(collection.StorageCollection): +class InputTextStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) @@ -179,10 +178,10 @@ class InputTextStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputText(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 08adce918c1..cc8495384b1 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.4.2", - "insteon-frontend-home-assistant==0.3.4" + "insteon-frontend-home-assistant==0.3.5" ], "usb": [ { diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 54e50b7b1de..d199b8808d3 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -197,6 +197,15 @@ class IntegrationSensor(RestoreEntity, SensorEntity): old_state: State | None = event.data.get("old_state") new_state: State | None = event.data.get("new_state") + if ( + source_state := self.hass.states.get(self._sensor_source_id) + ) is None or source_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + if new_state is None or new_state.state in ( STATE_UNKNOWN, STATE_UNAVAILABLE, diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 6bc3d88287f..2f5ea26a8a6 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) _LOGGER.debug( - "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s", + "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), name, area, domains, device_classes, + intent_obj.assistant, ) # Create response diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a23..70b53b80d9c 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_DIRECTION = "direction" CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ class IrishRailTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 3612e87f8e6..2f60490d8c8 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -25,19 +25,15 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, CONF_IGNORE_STRING, CONF_NETWORK, - CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, - DEFAULT_RESTORE_LIGHT_STATE, DEFAULT_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, DOMAIN, @@ -55,90 +51,16 @@ from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TLS_VER): vol.Coerce(float), - vol.Optional( - CONF_IGNORE_STRING, default=DEFAULT_IGNORE_STRING - ): cv.string, - vol.Optional( - CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING - ): cv.string, - vol.Optional( - CONF_VAR_SENSOR_STRING, default=DEFAULT_VAR_SENSOR_STRING - ): cv.string, - vol.Required( - CONF_RESTORE_LIGHT_STATE, default=DEFAULT_RESTORE_LIGHT_STATE - ): bool, - }, - ) - }, - ), + cv.deprecated(DOMAIN), extra=vol.ALLOW_EXTRA, ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the isy994 integration from YAML.""" - isy_config: ConfigType | None = config.get(DOMAIN) - hass.data.setdefault(DOMAIN, {}) - - if not isy_config: - return True - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - # Only import if we haven't before. - config_entry = _async_find_matching_config_entry(hass) - if not config_entry: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=dict(isy_config), - ) - ) - return True - - # Update the entry based on the YAML configuration, in case it changed. - hass.config_entries.async_update_entry(config_entry, data=dict(isy_config)) - return True - - -@callback -def _async_find_matching_config_entry( - hass: HomeAssistant, -) -> config_entries.ConfigEntry | None: - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.source == config_entries.SOURCE_IMPORT: - return entry - return None - - async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Set up the ISY 994 integration.""" - # As there currently is no way to import options from yaml - # when setting up a config entry, we fallback to adding - # the options to the config entry and pull them out here if - # they are missing from the options - _async_import_options_from_data_if_missing(hass, entry) - + hass.data.setdefault(DOMAIN, {}) isy_data = hass.data[DOMAIN][entry.entry_id] = IsyData() isy_config = entry.data @@ -268,25 +190,6 @@ async def _async_update_listener( await hass.config_entries.async_reload(entry.entry_id) -@callback -def _async_import_options_from_data_if_missing( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> None: - options = dict(entry.options) - modified = False - for importable_option in ( - CONF_IGNORE_STRING, - CONF_SENSOR_STRING, - CONF_RESTORE_LIGHT_STATE, - ): - if importable_option not in entry.options and importable_option in entry.data: - options[importable_option] = entry.data[importable_option] - modified = True - - if modified: - hass.config_entries.async_update_entry(entry, options=options) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index c9dd0f82668..621b17f096e 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -400,12 +400,18 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): Insteon leak sensors set their primary node to On when the state is DRY, not WET, so we invert the binary state if the user indicates that it is a moisture sensor. + + Dusk/Dawn sensors set their node to On when DUSK, not light detected, + so this is inverted as well. """ if self._computed_state is None: - # Do this first so we don't invert None on moisture sensors + # Do this first so we don't invert None on moisture or light sensors return None - if self.device_class == BinarySensorDeviceClass.MOISTURE: + if self.device_class in ( + BinarySensorDeviceClass.LIGHT, + BinarySensorDeviceClass.MOISTURE, + ): return not self._computed_state return self._computed_state diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 0b61b14d9b1..d6bbf236c13 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -168,10 +168,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import.""" - return await self.async_step_user(user_input) - async def _async_set_unique_id_or_update( self, isy_mac: str, ip_address: str, port: int | None ) -> None: diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 37ae1a82b91..686ffdb72f3 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -335,8 +335,8 @@ UOM_FRIENDLY_NAME = { "18": UnitOfLength.FEET, "19": UnitOfTime.HOURS, "20": UnitOfTime.HOURS, - "21": "%AH", - "22": "%RH", + "21": PERCENTAGE, + "22": PERCENTAGE, "23": UnitOfPressure.INHG, "24": UnitOfVolumetricFlux.INCHES_PER_HOUR, UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 97f3c669772..4504cde713e 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE, UOM_BARRIER +from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity @@ -63,8 +63,7 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Send the open cover command to the ISY cover device.""" - val = 100 if self._node.uom == UOM_BARRIER else None - if not await self._node.turn_on(val=val): + if not await self._node.turn_on(): _LOGGER.error("Unable to open the cover") async def async_close_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 0b62f2bd144..8c64e5b9d55 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -10,19 +10,13 @@ from pyisy.nodes import Node from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE from .entity import ISYNodeEntity -from .services import ( - SERVICE_SET_ON_LEVEL, - async_log_deprecated_service_call, - async_setup_light_services, -) ATTR_LAST_BRIGHTNESS = "last_brightness" @@ -43,7 +37,6 @@ async def async_setup_entry( ) async_add_entities(entities) - async_setup_light_services(hass) class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): @@ -127,35 +120,3 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): and last_state.attributes[ATTR_LAST_BRIGHTNESS] ): self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS] - - async def async_set_on_level(self, value: int) -> None: - """Set the ON Level for a device.""" - entity_registry = er.async_get(self.hass) - async_log_deprecated_service_call( - self.hass, - call=ServiceCall(domain=DOMAIN, service=SERVICE_SET_ON_LEVEL), - alternate_service="number.set_value", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{self._node.isy.uuid}_{self._node.address}_OL", - ), - breaks_in_ha_version="2023.5.0", - ) - await self._node.set_on_level(value) - - async def async_set_ramp_rate(self, value: int) -> None: - """Set the Ramp Rate for a device.""" - entity_registry = er.async_get(self.hass) - async_log_deprecated_service_call( - self.hass, - call=ServiceCall(domain=DOMAIN, service=SERVICE_SET_ON_LEVEL), - alternate_service="select.select_option", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{self._node.isy.uuid}_{self._node.address}_RR", - ), - breaks_in_ha_version="2023.5.0", - ) - await self._node.set_ramp_rate(value) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index ea66bc90130..7d7696755cf 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -10,36 +10,17 @@ from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND, CONF_NAME, - CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, - SERVICE_RELOAD, - Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms -import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import entity_service_call -from .const import _LOGGER, CONF_NETWORK, DOMAIN, ISY_CONF_NAME -from .util import _async_cleanup_registry_entries +from .const import _LOGGER, DOMAIN # Common Services for All Platforms: -SERVICE_SYSTEM_QUERY = "system_query" -SERVICE_SET_VARIABLE = "set_variable" SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" -SERVICE_RUN_NETWORK_RESOURCE = "run_network_resource" -SERVICE_CLEANUP = "cleanup_entities" - -INTEGRATION_SERVICES = [ - SERVICE_SYSTEM_QUERY, - SERVICE_SET_VARIABLE, - SERVICE_SEND_PROGRAM_COMMAND, - SERVICE_RUN_NETWORK_RESOURCE, - SERVICE_CLEANUP, -] # Entity specific methods (valid for most Groups/ISY Scenes, Lights, Switches, Fans) SERVICE_SEND_RAW_NODE_COMMAND = "send_raw_node_command" @@ -48,10 +29,6 @@ SERVICE_GET_ZWAVE_PARAMETER = "get_zwave_parameter" SERVICE_SET_ZWAVE_PARAMETER = "set_zwave_parameter" SERVICE_RENAME_NODE = "rename_node" -# Services valid only for dimmable lights. -SERVICE_SET_ON_LEVEL = "set_on_level" -SERVICE_SET_RAMP_RATE = "set_ramp_rate" - # Services valid only for Z-Wave Locks SERVICE_SET_ZWAVE_LOCK_USER_CODE = "set_zwave_lock_user_code" SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code" @@ -102,18 +79,6 @@ def valid_isy_commands(value: Any) -> str: SCHEMA_GROUP = "name-address" -SERVICE_SYSTEM_QUERY_SCHEMA = vol.Schema( - {vol.Optional(CONF_ADDRESS): cv.string, vol.Optional(CONF_ISY): cv.string} -) - -SERVICE_SET_RAMP_RATE_SCHEMA = { - vol.Required(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 31)) -} - -SERVICE_SET_VALUE_SCHEMA = { - vol.Required(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 255)) -} - SERVICE_SEND_RAW_NODE_COMMAND_SCHEMA = { vol.Required(CONF_COMMAND): vol.All(cv.string, valid_isy_commands), vol.Optional(CONF_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 255)), @@ -142,22 +107,6 @@ SERVICE_SET_USER_CODE_SCHEMA = { SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)} -SERVICE_SET_VARIABLE_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME), - vol.Schema( - { - vol.Exclusive(CONF_NAME, SCHEMA_GROUP): cv.string, - vol.Inclusive(CONF_ADDRESS, SCHEMA_GROUP): vol.Coerce(int), - vol.Inclusive(CONF_TYPE, SCHEMA_GROUP): vol.All( - vol.Coerce(int), vol.Range(1, 2) - ), - vol.Optional(CONF_INIT, default=False): bool, - vol.Required(CONF_VALUE): vol.Coerce(int), - vol.Optional(CONF_ISY): cv.string, - } - ), -) - SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_NAME), vol.Schema( @@ -170,108 +119,15 @@ SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( ), ) -SERVICE_RUN_NETWORK_RESOURCE_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_ADDRESS, CONF_NAME), - vol.Schema( - { - vol.Exclusive(CONF_NAME, SCHEMA_GROUP): cv.string, - vol.Exclusive(CONF_ADDRESS, SCHEMA_GROUP): vol.Coerce(int), - vol.Optional(CONF_ISY): cv.string, - } - ), -) - @callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Create and register services for the ISY integration.""" existing_services = hass.services.async_services().get(DOMAIN) - if existing_services and any( - service in INTEGRATION_SERVICES for service in existing_services - ): + if existing_services and SERVICE_SEND_PROGRAM_COMMAND in existing_services: # Integration-level services have already been added. Return. return - async def async_system_query_service_handler(service: ServiceCall) -> None: - """Handle a system query service call.""" - address = service.data.get(CONF_ADDRESS) - isy_name = service.data.get(CONF_ISY) - entity_registry = er.async_get(hass) - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf["name"]: - continue - # If an address is provided, make sure we query the correct ISY. - # Otherwise, query the whole system on all ISY's connected. - if address and isy.nodes.get_by_id(address) is not None: - _LOGGER.debug( - "Requesting query of device %s on ISY %s", - address, - isy.uuid, - ) - await isy.query(address) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, - DOMAIN, - f"{isy.uuid}_{address}_query", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.debug("Requesting system query of ISY %s", isy.uuid) - await isy.query() - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, DOMAIN, f"{isy.uuid}_query" - ), - breaks_in_ha_version="2023.5.0", - ) - - async def async_run_network_resource_service_handler(service: ServiceCall) -> None: - """Handle a network resource service call.""" - address = service.data.get(CONF_ADDRESS) - name = service.data.get(CONF_NAME) - isy_name = service.data.get(CONF_ISY) - - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf[ISY_CONF_NAME]: - continue - if isy.networking is None: - continue - command = None - if address: - command = isy.networking.get_by_id(address) - if name: - command = isy.networking.get_by_name(name) - if command is not None: - await command.run() - entity_registry = er.async_get(hass) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="button.press", - alternate_target=entity_registry.async_get_entity_id( - Platform.BUTTON, - DOMAIN, - f"{isy.uuid}_{CONF_NETWORK}_{address}", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.error( - "Could not run network resource command; not found or enabled on the ISY" - ) - async def async_send_program_command_service_handler(service: ServiceCall) -> None: """Handle a send program command service call.""" address = service.data.get(CONF_ADDRESS) @@ -294,81 +150,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return _LOGGER.error("Could not send program command; not found or enabled on the ISY") - async def async_set_variable_service_handler(service: ServiceCall) -> None: - """Handle a set variable service call.""" - address = service.data.get(CONF_ADDRESS) - vtype = service.data.get(CONF_TYPE) - name = service.data.get(CONF_NAME) - value = service.data.get(CONF_VALUE) - init = service.data.get(CONF_INIT, False) - isy_name = service.data.get(CONF_ISY) - - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root - if isy_name and isy_name != isy.conf["name"]: - continue - variable = None - if name: - variable = isy.variables.get_by_name(name) - if address and vtype: - variable = isy.variables.vobjs[vtype].get(address) - if variable is not None: - await variable.set_value(value, init) - entity_registry = er.async_get(hass) - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="number.set_value", - alternate_target=entity_registry.async_get_entity_id( - Platform.NUMBER, - DOMAIN, - f"{isy.uuid}_{address}{'_init' if init else ''}", - ), - breaks_in_ha_version="2023.5.0", - ) - return - _LOGGER.error("Could not set variable value; not found or enabled on the ISY") - - @callback - def async_cleanup_registry_entries(service: ServiceCall) -> None: - """Remove extra entities that are no longer part of the integration.""" - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="homeassistant.reload_core_config", - alternate_target=None, - breaks_in_ha_version="2023.5.0", - ) - for config_entry_id in hass.data[DOMAIN]: - _async_cleanup_registry_entries(hass, config_entry_id) - - async def async_reload_config_entries(service: ServiceCall) -> None: - """Trigger a reload of all ISY config entries.""" - async_log_deprecated_service_call( - hass, - call=service, - alternate_service="homeassistant.reload_core_config", - alternate_target=None, - breaks_in_ha_version="2023.5.0", - ) - for config_entry_id in hass.data[DOMAIN]: - hass.async_create_task(hass.config_entries.async_reload(config_entry_id)) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_SYSTEM_QUERY, - service_func=async_system_query_service_handler, - schema=SERVICE_SYSTEM_QUERY_SCHEMA, - ) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_RUN_NETWORK_RESOURCE, - service_func=async_run_network_resource_service_handler, - schema=SERVICE_RUN_NETWORK_RESOURCE_SCHEMA, - ) - hass.services.async_register( domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND, @@ -376,23 +157,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_SEND_PROGRAM_COMMAND_SCHEMA, ) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_SET_VARIABLE, - service_func=async_set_variable_service_handler, - schema=SERVICE_SET_VARIABLE_SCHEMA, - ) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_CLEANUP, - service_func=async_cleanup_registry_entries, - ) - - hass.services.async_register( - domain=DOMAIN, service=SERVICE_RELOAD, service_func=async_reload_config_entries - ) - async def _async_send_raw_node_command(call: ServiceCall) -> None: await entity_service_call( hass, async_get_platforms(hass, DOMAIN), "async_send_raw_node_command", call @@ -462,74 +226,12 @@ def async_unload_services(hass: HomeAssistant) -> None: return existing_services = hass.services.async_services().get(DOMAIN) - if not existing_services or not any( - service in INTEGRATION_SERVICES for service in existing_services - ): + if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return _LOGGER.info("Unloading ISY994 Services") - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SYSTEM_QUERY) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_RUN_NETWORK_RESOURCE) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_VARIABLE) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_CLEANUP) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_RELOAD) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_RAW_NODE_COMMAND) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_NODE_COMMAND) - - -@callback -def async_setup_light_services(hass: HomeAssistant) -> None: - """Create device-specific services for the ISY Integration.""" - platform = entity_platform.async_get_current_platform() - - platform.async_register_entity_service( - SERVICE_SET_ON_LEVEL, SERVICE_SET_VALUE_SCHEMA, "async_set_on_level" - ) - platform.async_register_entity_service( - SERVICE_SET_RAMP_RATE, SERVICE_SET_RAMP_RATE_SCHEMA, "async_set_ramp_rate" - ) - - -@callback -def async_log_deprecated_service_call( - hass: HomeAssistant, - call: ServiceCall, - alternate_service: str, - alternate_target: str | None, - breaks_in_ha_version: str, -) -> None: - """Log a warning about a deprecated service call.""" - deprecated_service = f"{call.domain}.{call.service}" - alternate_target = alternate_target or "this device" - - async_create_issue( - hass, - DOMAIN, - f"deprecated_service_{deprecated_service}", - breaks_in_ha_version=breaks_in_ha_version, - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders={ - "alternate_service": alternate_service, - "alternate_target": alternate_target, - "deprecated_service": deprecated_service, - }, - ) - - alternate_text = "" - if alternate_target: - alternate_text = f' and pass it a target entity ID of "{alternate_target}"' - - _LOGGER.warning( - ( - 'The "%s" service is deprecated and will be removed in %s; use the "%s" ' - "service %s" - ), - deprecated_service, - breaks_in_ha_version, - alternate_service, - alternate_text, - ) + hass.services.async_remove(domain=DOMAIN, service=SERVICE_GET_ZWAVE_PARAMETER) + hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_ZWAVE_PARAMETER) diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index 89b6c4d33d3..b84fcdd73ef 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -181,98 +181,6 @@ rename_node: example: "Front Door Light" selector: text: -set_on_level: - name: Set On Level (Deprecated) - description: "Send a ISY set_on_level command to a Node. Deprecated: Use On Level Number entity instead." - target: - entity: - integration: isy994 - domain: light - fields: - value: - name: Value - description: integer value to set. - required: true - selector: - number: - min: 0 - max: 255 -set_ramp_rate: - name: Set ramp rate (Deprecated) - description: "Send a ISY set_ramp_rate command to a Node. Deprecated: Use On Level Number entity instead." - target: - entity: - integration: isy994 - domain: light - fields: - value: - name: Value - description: Integer value to set, see PyISY/ISY documentation for values to actual ramp times. - required: true - selector: - number: - min: 0 - max: 31 -system_query: - name: System query (Deprecated) - description: "Request the ISY Query the connected devices. Deprecated: Use device Query button entity." - fields: - address: - name: Address - description: ISY Address to Query. Omitting this requests a system-wide scan (typically scheduled once per day). - example: "1A 2B 3C 1" - selector: - text: - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). Omitting this will cause all ISYs to be queried. - example: "ISY" - selector: - text: -set_variable: - name: Set variable (Deprecated) - description: "Set an ISY variable's current or initial value. Variables can be set by either type/address or by name. Deprecated: Use number entities instead." - fields: - address: - name: Address - description: The address of the variable for which to set the value. - selector: - number: - min: 0 - max: 255 - type: - name: Type - description: The variable type, 1 = Integer, 2 = State. - selector: - number: - min: 1 - max: 2 - name: - name: Name - description: The name of the variable to set (use instead of type/address). - example: "my_variable_name" - selector: - text: - init: - name: Init - description: If True, the initial (init) value will be updated instead of the current value. - default: false - selector: - boolean: - value: - name: Value - description: The integer value to be sent. - required: true - selector: - number: - min: 0 - max: 255 - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same variable name or address on multiple ISYs, omitting this will run the command on them all. - example: "ISY" - selector: - text: send_program_command: name: Send program command description: >- @@ -312,32 +220,3 @@ send_program_command: example: "ISY" selector: text: -run_network_resource: - name: Run network resource (Deprecated) - description: "Run a network resource on the ISY. Deprecated: Use Network Resource button entity." - fields: - address: - name: Address - description: The address of the network resource to execute (use either address or name). - selector: - number: - min: 0 - max: 255 - name: - name: Name - description: The name of the network resource to execute (use either address or name). - example: "Network Resource 1" - selector: - text: - isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same resource name or address on multiple ISYs, omitting this will run the command on them all. - example: "ISY" - selector: - text: -reload: - name: Reload - description: Reload the ISY connection(s) without restarting Home Assistant. Use to pick up new devices that have been added or changed on the ISY. -cleanup_entities: - name: Cleanup entities - description: Cleanup old entities and devices no longer used by the ISY integration. Useful if you've removed devices from the ISY or changed the options in the configuration to exclude additional items. diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 69852394890..821f8889978 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -53,22 +53,5 @@ "last_heartbeat": "Last Heartbeat Time", "websocket_status": "Event Socket Status" } - }, - "issues": { - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - }, - "deprecated_yaml": { - "title": "The ISY/IoX YAML configuration is being removed", - "description": "Configuring Universal Devices ISY/IoX using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `isy994` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } - } - } } } diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 60104545dea..91d1d9fa1c5 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -12,7 +12,7 @@ from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTArray, DPTBase, DPTBinary -from xknx.exceptions import ConversionError, XKNXException +from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException from xknx.io import ConnectionConfig, ConnectionType, SecureConfig from xknx.telegram import AddressFilter, Telegram from xknx.telegram.address import ( @@ -513,31 +513,29 @@ class KNXModule: ) ): data = telegram.payload.value.value - - if isinstance(data, tuple): - if transcoder := ( - self._group_address_transcoder.get(telegram.destination_address) - or next( + if transcoder := ( + self._group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(telegram.payload.value) + except (ConversionError, CouldNotParseTelegram) as err: + _LOGGER.warning( ( - _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() - if _filter.match(telegram.destination_address) + "Error in `knx_event` at decoding type '%s' from" + " telegram %s\n%s" ), - None, + transcoder.__name__, + telegram, + err, ) - ): - try: - value = transcoder.from_knx(data) - except ConversionError as err: - _LOGGER.warning( - ( - "Error in `knx_event` at decoding type '%s' from" - " telegram %s\n%s" - ), - transcoder.__name__, - telegram, - err, - ) self.hass.bus.async_fire( "knx_event", @@ -656,7 +654,7 @@ class KNXModule: transcoder = DPTBase.parse_transcoder(attr_type) if transcoder is None: raise ValueError(f"Invalid type for knx.send service: {attr_type}") - payload = DPTArray(transcoder.to_knx(attr_payload)) + payload = transcoder.to_knx(attr_payload) elif isinstance(attr_payload, int): payload = DPTBinary(attr_payload) else: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 85e23cbe547..81610d62dcf 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -9,10 +9,15 @@ from typing import Any, Final import voluptuous as vol from xknx import XKNX -from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration +from xknx.exceptions.exception import ( + CommunicationError, + InvalidSecureConfiguration, + XKNXException, +) from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner from xknx.io.self_description import request_description +from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface, sync_load_keyring from homeassistant.components.file_upload import process_uploaded_file @@ -258,21 +263,25 @@ class KNXCommonFlow(ABC, FlowHandler): if user_input is not None: try: - _host = ip_v4_validator(user_input[CONF_HOST], multicast=False) - except vol.Invalid: + _host = user_input[CONF_HOST] + _host_ip = await xknx_validate_ip(_host) + ip_v4_validator(_host_ip, multicast=False) + except (vol.Invalid, XKNXException): errors[CONF_HOST] = "invalid_ip_address" - if _local_ip := user_input.get(CONF_KNX_LOCAL_IP): + _local_ip = None + if _local := user_input.get(CONF_KNX_LOCAL_IP): try: - _local_ip = ip_v4_validator(_local_ip, multicast=False) - except vol.Invalid: + _local_ip = await xknx_validate_ip(_local) + ip_v4_validator(_local_ip, multicast=False) + except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" selected_tunnelling_type = user_input[CONF_KNX_TUNNELING_TYPE] if not errors: try: self._selected_tunnel = await request_description( - gateway_ip=_host, + gateway_ip=_host_ip, gateway_port=user_input[CONF_PORT], local_ip=_local_ip, route_back=user_input[CONF_KNX_ROUTE_BACK], @@ -296,7 +305,7 @@ class KNXCommonFlow(ABC, FlowHandler): host=_host, port=user_input[CONF_PORT], route_back=user_input[CONF_KNX_ROUTE_BACK], - local_ip=_local_ip, + local_ip=_local, device_authentication=None, user_id=None, user_password=None, @@ -636,10 +645,11 @@ class KNXCommonFlow(ABC, FlowHandler): ip_v4_validator(_multicast_group, multicast=True) except vol.Invalid: errors[CONF_KNX_MCAST_GRP] = "invalid_ip_address" - if _local_ip := user_input.get(CONF_KNX_LOCAL_IP): + if _local := user_input.get(CONF_KNX_LOCAL_IP): try: + _local_ip = await xknx_validate_ip(_local) ip_v4_validator(_local_ip, multicast=False) - except vol.Invalid: + except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" if not errors: @@ -653,7 +663,7 @@ class KNXCommonFlow(ABC, FlowHandler): individual_address=_individual_address, multicast_group=_multicast_group, multicast_port=_multicast_port, - local_ip=_local_ip, + local_ip=_local, device_authentication=None, user_id=None, user_password=None, diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 0ad4404290a..d3aeced46c9 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.7.0"] + "requirements": ["xknx==2.9.0"] } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index a505714c0d0..0f627b724cb 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -10,7 +10,7 @@ from typing import Any, ClassVar, Final import voluptuous as vol from xknx.devices.climate import SetpointShiftMode from xknx.dpt import DPTBase, DPTNumeric, DPTString -from xknx.exceptions import ConversionError, CouldNotParseAddress +from xknx.exceptions import ConversionError, CouldNotParseAddress, CouldNotParseTelegram from xknx.telegram.address import IndividualAddress, parse_device_group_address from homeassistant.components.binary_sensor import ( @@ -185,13 +185,13 @@ def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length try: - entity_config[CONF_PAYLOAD] = int.from_bytes( - transcoder.to_knx(_payload), byteorder="big" - ) - except ConversionError as ex: + _dpt_payload = transcoder.to_knx(_payload) + _raw_payload = transcoder.validate_payload(_dpt_payload) + except (ConversionError, CouldNotParseTelegram) as ex: raise vol.Invalid( f"'payload: {_payload}' not valid for 'type: {_type}'" ) from ex + entity_config[CONF_PAYLOAD] = int.from_bytes(_raw_payload, byteorder="big") return entity_config _payload = entity_config[CONF_PAYLOAD] diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ef153985342..ea5ba2f63a6 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -53,7 +53,6 @@ class KNXSystemEntityDescription(SensorEntityDescription): SYSTEM_ENTITY_DESCRIPTIONS = ( KNXSystemEntityDescription( key="individual_address", - name="Individual Address", always_available=False, icon="mdi:router-network", should_poll=False, @@ -61,7 +60,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="connected_since", - name="Connected since", always_available=False, device_class=SensorDeviceClass.TIMESTAMP, should_poll=False, @@ -69,7 +67,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="connection_type", - name="Connection type", always_available=False, device_class=SensorDeviceClass.ENUM, options=[opt.value for opt in XknxConnectionType], @@ -78,7 +75,6 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_incoming", - name="Telegrams incoming", icon="mdi:upload-network", entity_registry_enabled_default=False, force_update=True, @@ -87,14 +83,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_incoming_error", - name="Telegrams incoming Error", icon="mdi:help-network", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming_error, ), KNXSystemEntityDescription( key="telegrams_outgoing", - name="Telegrams outgoing", icon="mdi:download-network", entity_registry_enabled_default=False, force_update=True, @@ -103,14 +97,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( ), KNXSystemEntityDescription( key="telegrams_outgoing_error", - name="Telegrams outgoing Error", icon="mdi:close-network", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing_error, ), KNXSystemEntityDescription( key="telegram_count", - name="Telegrams", icon="mdi:plus-network", force_update=True, state_class=SensorStateClass.TOTAL_INCREASING, @@ -192,6 +184,8 @@ class KNXSensor(KnxEntity, SensorEntity): class KNXSystemSensor(SensorEntity): """Representation of a KNX system sensor.""" + _attr_has_entity_name = True + def __init__( self, knx: KNXModule, @@ -203,6 +197,7 @@ class KNXSystemSensor(SensorEntity): self._attr_device_info = knx.interface_device.device_info self._attr_should_poll = description.should_poll + self._attr_translation_key = description.key self._attr_unique_id = f"_{knx.entry.entry_id}_{description.key}" @property diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index a781f9d73cc..0fce778c521 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -23,13 +23,13 @@ "port": "[%key:common::config_flow::data::port%]", "host": "[%key:common::config_flow::data::host%]", "route_back": "Route back / NAT mode", - "local_ip": "Local IP of Home Assistant" + "local_ip": "Local IP interface" }, "data_description": { "port": "Port of the KNX/IP tunneling device.", - "host": "IP address of the KNX/IP tunneling device.", + "host": "IP address or hostname of the KNX/IP tunneling device.", "route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.", - "local_ip": "Leave blank to use auto-discovery." + "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, "secure_key_source": { @@ -93,11 +93,11 @@ "routing_secure": "Use KNX IP Secure", "multicast_group": "Multicast group", "multicast_port": "Multicast port", - "local_ip": "Local IP of Home Assistant" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`", - "local_ip": "Leave blank to use auto-discovery." + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } }, @@ -253,5 +253,33 @@ "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } + }, + "entity": { + "sensor": { + "individual_address": { + "name": "Individual address" + }, + "connected_since": { + "name": "Connection established" + }, + "connection_type": { + "name": "Connection type" + }, + "telegrams_incoming": { + "name": "Incoming telegrams" + }, + "telegrams_incoming_error": { + "name": "Incoming telegram errors" + }, + "telegrams_outgoing": { + "name": "Outgoing telegrams" + }, + "telegrams_outgoing_error": { + "name": "Outgoing telegram errors" + }, + "telegram_count": { + "name": "Telegrams" + } + } } } diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd41..0279af2e610 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 244515a07d4..9669648b4c5 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -54,6 +54,15 @@ class HeatMeterSensorEntityDescription( HEAT_METER_SENSOR_TYPES = ( + HeatMeterSensorEntityDescription( + key="heat_usage_mwh", + icon="mdi:fire", + name="Heat usage MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: res.heat_usage_mwh, + ), HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -61,7 +70,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "volume_usage_m3", None), + value_fn=lambda res: res.volume_usage_m3, ), HeatMeterSensorEntityDescription( key="heat_usage_gj", @@ -70,7 +79,16 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda res: getattr(res, "heat_usage_gj", None), + value_fn=lambda res: res.heat_usage_gj, + ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_mwh", + icon="mdi:fire", + name="Heat previous year MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: res.heat_previous_year_mwh, ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", @@ -79,7 +97,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), + value_fn=lambda res: res.heat_previous_year_gj, ), HeatMeterSensorEntityDescription( key="volume_previous_year_m3", @@ -88,28 +106,28 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "volume_previous_year_m3", None), + value_fn=lambda res: res.volume_previous_year_m3, ), HeatMeterSensorEntityDescription( key="ownership_number", name="Ownership number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "ownership_number", None), + value_fn=lambda res: res.ownership_number, ), HeatMeterSensorEntityDescription( key="error_number", name="Error number", icon="mdi:home-alert", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "error_number", None), + value_fn=lambda res: res.error_number, ), HeatMeterSensorEntityDescription( key="device_number", name="Device number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "device_number", None), + value_fn=lambda res: res.device_number, ), HeatMeterSensorEntityDescription( key="measurement_period_minutes", @@ -117,7 +135,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measurement_period_minutes", None), + value_fn=lambda res: res.measurement_period_minutes, ), HeatMeterSensorEntityDescription( key="power_max_kw", @@ -125,7 +143,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_kw", None), + value_fn=lambda res: res.power_max_kw, ), HeatMeterSensorEntityDescription( key="power_max_previous_year_kw", @@ -133,7 +151,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "power_max_previous_year_kw", None), + value_fn=lambda res: res.power_max_previous_year_kw, ), HeatMeterSensorEntityDescription( key="flowrate_max_m3ph", @@ -141,7 +159,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_m3ph", None), + value_fn=lambda res: res.flowrate_max_m3ph, ), HeatMeterSensorEntityDescription( key="flowrate_max_previous_year_m3ph", @@ -149,7 +167,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flowrate_max_previous_year_m3ph", None), + value_fn=lambda res: res.flowrate_max_previous_year_m3ph, ), HeatMeterSensorEntityDescription( key="return_temperature_max_c", @@ -157,7 +175,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "return_temperature_max_c", None), + value_fn=lambda res: res.return_temperature_max_c, ), HeatMeterSensorEntityDescription( key="return_temperature_max_previous_year_c", @@ -165,9 +183,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr( - res, "return_temperature_max_previous_year_c", None - ), + value_fn=lambda res: res.return_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_c", @@ -175,7 +191,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_c", None), + value_fn=lambda res: res.flow_temperature_max_c, ), HeatMeterSensorEntityDescription( key="flow_temperature_max_previous_year_c", @@ -183,7 +199,7 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_temperature_max_previous_year_c", None), + value_fn=lambda res: res.flow_temperature_max_previous_year_c, ), HeatMeterSensorEntityDescription( key="operating_hours", @@ -191,7 +207,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "operating_hours", None), + value_fn=lambda res: res.operating_hours, ), HeatMeterSensorEntityDescription( key="flow_hours", @@ -199,7 +215,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "flow_hours", None), + value_fn=lambda res: res.flow_hours, ), HeatMeterSensorEntityDescription( key="fault_hours", @@ -207,7 +223,7 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours", None), + value_fn=lambda res: res.fault_hours, ), HeatMeterSensorEntityDescription( key="fault_hours_previous_year", @@ -215,21 +231,21 @@ HEAT_METER_SENSOR_TYPES = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "fault_hours_previous_year", None), + value_fn=lambda res: res.fault_hours_previous_year, ), HeatMeterSensorEntityDescription( key="yearly_set_day", name="Yearly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "yearly_set_day", None), + value_fn=lambda res: res.yearly_set_day, ), HeatMeterSensorEntityDescription( key="monthly_set_day", name="Monthly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "monthly_set_day", None), + value_fn=lambda res: res.monthly_set_day, ), HeatMeterSensorEntityDescription( key="meter_date_time", @@ -247,13 +263,13 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "measuring_range_m3ph", None), + value_fn=lambda res: res.measuring_range_m3ph, ), HeatMeterSensorEntityDescription( key="settings_and_firmware", name="Settings and firmware", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda res: getattr(res, "settings_and_firmware", None), + value_fn=lambda res: res.settings_and_firmware, ), ) @@ -277,7 +293,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -306,6 +321,14 @@ class HeatMeterSensor( self.entity_description = description self._attr_device_info = device + if ( + description.native_unit_of_measurement + in {UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.MEGA_WATT_HOUR} + and self.native_value is None + ): + # Some meters will return MWh, others will return GJ. + self._attr_entity_registry_enabled_default = False + @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 2998047a7ac..392da95a2ac 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lastfm", "iot_class": "cloud_polling", "loggers": ["pylast"], - "requirements": ["pylast==4.2.1"] + "requirements": ["pylast==5.1.0"] } diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817bc..a25171f9c2e 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -3,10 +3,8 @@ from __future__ import annotations import hashlib import logging -import re -import pylast as lastfm -from pylast import WSError +from pylast import LastFMNetwork, Track, User, WSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -16,7 +14,9 @@ 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__) +LOGGER = logging.getLogger(__name__) + +CONF_USERS = "users" ATTR_LAST_PLAYED = "last_played" ATTR_PLAY_COUNT = "play_count" @@ -24,10 +24,6 @@ ATTR_TOP_PLAYED = "top_played" STATE_NOT_SCROBBLING = "Not Scrobbling" -CONF_USERS = "users" - -ICON = "mdi:radio-fm" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -36,6 +32,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +def format_track(track: Track) -> str: + """Format the track.""" + return f"{track.artist} - {track.title}" + + def setup_platform( hass: HomeAssistant, config: ConfigType, @@ -43,92 +44,46 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Last.fm sensor platform.""" - api_key = config[CONF_API_KEY] - users = config[CONF_USERS] - - lastfm_api = lastfm.LastFMNetwork(api_key=api_key) - + lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY]) entities = [] - for username in users: + for username in config[CONF_USERS]: try: - lastfm_api.get_user(username).get_image() - entities.append(LastfmSensor(username, lastfm_api)) - except WSError as error: - _LOGGER.error(error) + 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) -class LastfmSensor(SensorEntity): +class LastFmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" - def __init__(self, user, lastfm_api): + def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None: """Initialize the sensor.""" - self._unique_id = hashlib.sha256(user.encode("utf-8")).hexdigest() - self._user = lastfm_api.get_user(user) - self._name = user - self._lastfm = lastfm_api - self._state = "Not Scrobbling" - self._playcount = None - self._lastplayed = None - self._topplayed = None - self._cover = None - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id - - @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 + self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() + self._attr_name = user.name + self._user = user def update(self) -> None: """Update device state.""" - self._cover = self._user.get_image() - self._playcount = self._user.get_playcount() - - if recent_tracks := self._user.get_recent_tracks(limit=2): - last = recent_tracks[0] - self._lastplayed = f"{last.track.artist} - {last.track.title}" - + self._attr_entity_picture = self._user.get_image() + if now_playing := self._user.get_now_playing(): + self._attr_native_value = format_track(now_playing) + else: + self._attr_native_value = STATE_NOT_SCROBBLING + top_played = None if top_tracks := self._user.get_top_tracks(limit=1): - top = str(top_tracks[0]) - if (toptitle := re.search("', '(.+?)',", top)) and ( - topartist := re.search("'(.+?)',", top) - ): - self._topplayed = f"{topartist.group(1)} - {toptitle.group(1)}" - - if (now_playing := self._user.get_now_playing()) is None: - self._state = STATE_NOT_SCROBBLING - return - - self._state = f"{now_playing.artist} - {now_playing.title}" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_LAST_PLAYED: self._lastplayed, - ATTR_PLAY_COUNT: self._playcount, - ATTR_TOP_PLAYED: self._topplayed, + top_played = format_track(top_tracks[0].item) + last_played = None + if last_tracks := self._user.get_recent_tracks(limit=1): + last_played = format_track(last_tracks[0].track) + play_count = self._user.get_playcount() + self._attr_extra_state_attributes = { + ATTR_LAST_PLAYED: last_played, + ATTR_PLAY_COUNT: play_count, + ATTR_TOP_PLAYED: top_played, } - - @property - def entity_picture(self): - """Avatar of the user.""" - return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 6ba8bf3286c..4716519ac18 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==0.3.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==0.4.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 00726095052..a19680ffa5c 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==0.3.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==0.4.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 1bdbc618fdf..f0c38cdfb11 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -210,7 +210,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() - await coordinator.sensor_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: connection.async_stop() raise diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 1632cac3d1f..110661b1c5c 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( @@ -32,29 +32,24 @@ async def async_setup_entry( if lifx_features(coordinator.device)["hev"]: async_add_entities( - [ - LIFXHevCycleBinarySensorEntity( - coordinator=coordinator.sensor_coordinator, - description=HEV_CYCLE_STATE_SENSOR, - ) - ] + [LIFXHevCycleBinarySensorEntity(coordinator, HEV_CYCLE_STATE_SENSOR)] ) -class LIFXHevCycleBinarySensorEntity(LIFXSensorEntity, BinarySensorEntity): +class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._async_update_attrs() @callback diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 636f90aaf3b..b5f5373b3e8 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, IDENTIFY, RESTART -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, @@ -38,22 +38,21 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN] coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] async_add_entities( - cls(coordinator.sensor_coordinator) - for cls in (LIFXRestartButton, LIFXIdentifyButton) + [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] ) -class LIFXButton(LIFXSensorEntity, ButtonEntity): +class LIFXButton(LIFXEntity, ButtonEntity): """Base LIFX button.""" _attr_has_entity_name: bool = True _attr_should_poll: bool = False - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise a LIFX button.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator.parent.serial_number}_{self.entity_description.key}" + f"{coordinator.serial_number}_{self.entity_description.key}" ) diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 56a88c89806..22ac66e3bc9 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -18,12 +18,19 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from .const import _LOGGER, CONF_SERIAL, DOMAIN, TARGET_ANY +from .const import ( + _LOGGER, + CONF_SERIAL, + DEFAULT_ATTEMPTS, + DOMAIN, + OVERALL_TIMEOUT, + TARGET_ANY, +) from .discovery import async_discover_devices from .util import ( async_entry_is_legacy, - async_execute_lifx, async_get_legacy_entry, + async_multi_execute_lifx_with_retries, formatted_serial, lifx_features, mac_matches_serial_number, @@ -225,13 +232,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # get_version required for lifx_features() # get_label required to log the name of the device # get_group required to populate suggested areas - messages = await asyncio.gather( - *[ - async_execute_lifx(device.get_hostfirmware), - async_execute_lifx(device.get_version), - async_execute_lifx(device.get_label), - async_execute_lifx(device.get_group), - ] + messages = await async_multi_execute_lifx_with_retries( + [ + device.get_hostfirmware, + device.get_version, + device.get_label, + device.get_group, + ], + DEFAULT_ATTEMPTS, + OVERALL_TIMEOUT, ) except asyncio.TimeoutError: return None diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index af9dfa5a277..2208537b591 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -7,11 +7,22 @@ DOMAIN = "lifx" TARGET_ANY = "00:00:00:00:00:00" DISCOVERY_INTERVAL = 10 -MESSAGE_TIMEOUT = 1.65 -MESSAGE_RETRIES = 5 -OVERALL_TIMEOUT = 9 +# The number of seconds before we will no longer accept a response +# to a message and consider it invalid +MESSAGE_TIMEOUT = 18 +# Disable the retries in the library since they are not spaced out +# enough to account for WiFi and UDP dropouts +MESSAGE_RETRIES = 1 +OVERALL_TIMEOUT = 15 UNAVAILABLE_GRACE = 90 +# The number of times to retry a request message +DEFAULT_ATTEMPTS = 5 +# The maximum time to wait for a bulb to respond to an update +MAX_UPDATE_TIME = 90 +# The number of tries to send each request message to a bulb during an update +MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE = 5 + CONF_LABEL = "label" CONF_SERIAL = "serial" @@ -50,4 +61,5 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } DATA_LIFX_MANAGER = "lifx_manager" + _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 038f93c1e88..66cea18f119 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -28,20 +28,25 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, ATTR_REMAINING, + DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, + MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, + MAX_UPDATE_TIME, MESSAGE_RETRIES, MESSAGE_TIMEOUT, + OVERALL_TIMEOUT, TARGET_ANY, UNAVAILABLE_GRACE, ) from .util import ( async_execute_lifx, + async_multi_execute_lifx_with_retries, get_real_mac_addr, infrared_brightness_option_to_value, infrared_brightness_value_to_option, @@ -49,7 +54,6 @@ from .util import ( ) LIGHT_UPDATE_INTERVAL = 10 -SENSOR_UPDATE_INTERVAL = 30 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 RSSI_DBM_FW = AwesomeVersion("2.77") @@ -79,7 +83,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device: Light = connection.device self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF - self.sensor_coordinator = LIFXSensorUpdateCoordinator(hass, self, title) + self._update_rssi: bool = False + self._rssi: int = 0 + self.last_used_theme: str = "" super().__init__( hass, @@ -100,6 +106,24 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): self.device.retry_count = MESSAGE_RETRIES self.device.unregister_timeout = UNAVAILABLE_GRACE + @property + def rssi(self) -> int: + """Return stored RSSI value.""" + return self._rssi + + @property + def rssi_uom(self) -> str: + """Return the RSSI unit of measurement.""" + if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: + return SIGNAL_STRENGTH_DECIBELS + + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + @property + def current_infrared_brightness(self) -> str | None: + """Return the current infrared brightness as a string.""" + return infrared_brightness_value_to_option(self.device.infrared_brightness) + @property def serial_number(self) -> str: """Return the internal mac address.""" @@ -166,51 +190,84 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): platform, DOMAIN, f"{self.serial_number}_{key}" ) + async def _async_populate_device_info(self) -> None: + """Populate device info.""" + methods: list[Callable] = [] + device = self.device + if self.device.host_firmware_version is None: + methods.append(device.get_hostfirmware) + if self.device.product is None: + methods.append(device.get_version) + if self.device.group is None: + methods.append(device.get_group) + assert methods, "Device info already populated" + await async_multi_execute_lifx_with_retries( + methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT + ) + + @callback + def _async_build_color_zones_update_requests(self) -> list[Callable]: + """Build a color zones update request.""" + device = self.device + return [ + partial(device.get_color_zones, start_index=zone) + for zone in range(0, len(device.color_zones), 8) + ] + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" - async with self.lock: - if self.device.host_firmware_version is None: - self.device.get_hostfirmware() - if self.device.product is None: - self.device.get_version() - if self.device.group is None: - self.device.get_group() + device = self.device + if ( + device.host_firmware_version is None + or device.product is None + or device.group is None + ): + await self._async_populate_device_info() - response = await async_execute_lifx(self.device.get_color) + num_zones = len(device.color_zones) if device.color_zones is not None else 0 + features = lifx_features(self.device) + is_extended_multizone = features["extended_multizone"] + is_legacy_multizone = not is_extended_multizone and features["multizone"] + update_rssi = self._update_rssi + methods: list[Callable] = [self.device.get_color] + if update_rssi: + methods.append(self.device.get_wifiinfo) + if is_extended_multizone: + methods.append(self.device.get_extended_color_zones) + elif is_legacy_multizone: + methods.extend(self._async_build_color_zones_update_requests()) + if is_extended_multizone or is_legacy_multizone: + methods.append(self.device.get_multizone_effect) + if features["hev"]: + methods.append(self.device.get_hev_cycle) + if features["infrared"]: + methods.append(self.device.get_infrared) - if self.device.product is None: - raise UpdateFailed( - f"Failed to fetch get version from device: {self.device.ip_addr}" - ) + responses = await async_multi_execute_lifx_with_retries( + methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME + ) + # device.mac_addr is not the mac_address, its the serial number + if device.mac_addr == TARGET_ANY: + device.mac_addr = responses[0].target_addr - # device.mac_addr is not the mac_address, its the serial number - if self.device.mac_addr == TARGET_ANY: - self.device.mac_addr = response.target_addr + if update_rssi: + # We always send the rssi request second + self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5)) - # Update extended multizone devices - if lifx_features(self.device)["extended_multizone"]: - await self.async_get_extended_color_zones() - await self.async_get_multizone_effect() - # use legacy methods for older devices - elif lifx_features(self.device)["multizone"]: - await self.async_get_color_zones() - await self.async_get_multizone_effect() + if is_extended_multizone or is_legacy_multizone: + self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] + if is_legacy_multizone and num_zones != len(device.color_zones): + # The number of zones has changed so we need + # to update the zones again. This happens rarely. + await self.async_get_color_zones() async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" - zone = 0 - top = 1 - while zone < top: - # Each get_color_zones can update 8 zones at once - resp = await async_execute_lifx( - partial(self.device.get_color_zones, start_index=zone) - ) - zone += 8 - top = resp.count - - # We only await multizone responses so don't ask for just one - if zone == top - 1: - zone -= 1 + await async_multi_execute_lifx_with_retries( + self._async_build_color_zones_update_requests(), + DEFAULT_ATTEMPTS, + OVERALL_TIMEOUT, + ) async def async_get_extended_color_zones(self) -> None: """Get updated color information for all zones.""" @@ -294,11 +351,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): ) ) - async def async_get_multizone_effect(self) -> None: - """Update the device firmware effect running state.""" - await async_execute_lifx(self.device.get_multizone_effect) - self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] - async def async_set_multizone_effect( self, effect: str, @@ -357,64 +409,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return the enum value of the currently active firmware effect.""" return self.active_effect.value - -class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): - """DataUpdateCoordinator to gather data for a specific lifx device.""" - - def __init__( - self, - hass: HomeAssistant, - parent: LIFXUpdateCoordinator, - title: str, - ) -> None: - """Initialize DataUpdateCoordinator.""" - self.parent: LIFXUpdateCoordinator = parent - self.device: Light = parent.device - self._update_rssi: bool = False - self._rssi: int = 0 - self.last_used_theme: str = "" - - super().__init__( - hass, - _LOGGER, - name=f"{title} Sensors ({self.device.ip_addr})", - update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), - # Refresh immediately because the changes are not visible - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=0, immediate=True - ), - ) - - @property - def rssi(self) -> int: - """Return stored RSSI value.""" - return self._rssi - - @property - def rssi_uom(self) -> str: - """Return the RSSI unit of measurement.""" - if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: - return SIGNAL_STRENGTH_DECIBELS - - return SIGNAL_STRENGTH_DECIBELS_MILLIWATT - - @property - def current_infrared_brightness(self) -> str | None: - """Return the current infrared brightness as a string.""" - return infrared_brightness_value_to_option(self.device.infrared_brightness) - - async def _async_update_data(self) -> None: - """Fetch all device data from the api.""" - - if self._update_rssi is True: - await self.async_update_rssi() - - if lifx_features(self.device)["hev"]: - await self.async_get_hev_cycle() - - if lifx_features(self.device)["infrared"]: - await async_execute_lifx(self.device.get_infrared) - async def async_set_infrared_brightness(self, option: str) -> None: """Set infrared brightness.""" infrared_brightness = infrared_brightness_option_to_value(option) @@ -425,13 +419,13 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): bulb: Light = self.device if bulb.power_level: # just flash the bulb for three seconds - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) return # Turn the bulb on first, flash for 3 seconds, then turn off - await self.parent.async_set_power(state=True, duration=1) - await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await self.async_set_power(state=True, duration=1) + await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) await asyncio.sleep(LIFX_IDENTIFY_DELAY) - await self.parent.async_set_power(state=False, duration=1) + await self.async_set_power(state=False, duration=1) def async_enable_rssi_updates(self) -> Callable[[], None]: """Enable RSSI signal strength updates.""" @@ -444,22 +438,12 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): self._update_rssi = True return _async_disable_rssi_updates - async def async_update_rssi(self) -> None: - """Update RSSI value.""" - resp = await async_execute_lifx(self.device.get_wifiinfo) - self._rssi = int(floor(10 * log10(resp.signal) + 0.5)) - def async_get_hev_cycle_state(self) -> bool | None: """Return the current HEV cycle state.""" if self.device.hev_cycle is None: return None return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) - async def async_get_hev_cycle(self) -> None: - """Update the HEV cycle status from a LIFX Clean bulb.""" - if lifx_features(self.device)["hev"]: - await async_execute_lifx(self.device.get_hev_cycle) - async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: """Start or stop an HEV cycle on a LIFX Clean bulb.""" if lifx_features(self.device)["hev"]: @@ -471,4 +455,4 @@ class LIFXSensorUpdateCoordinator(DataUpdateCoordinator[None]): """Apply the selected theme to the device.""" self.last_used_theme = theme_name theme = ThemeLibrary().get_theme(theme_name) - await ThemePainter(self.hass.loop).paint(theme, [self.parent.device]) + await ThemePainter(self.hass.loop).paint(theme, [self.device]) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 63996d60027..a86bda53cfd 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .coordinator import LIFXUpdateCoordinator class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): @@ -27,21 +27,3 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): sw_version=self.bulb.host_firmware_version, suggested_area=self.bulb.group, ) - - -class LIFXSensorEntity(CoordinatorEntity[LIFXSensorUpdateCoordinator]): - """Representation of a LIFX sensor entity with a sensor coordinator.""" - - def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.bulb = coordinator.parent.device - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.parent.serial_number)}, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.parent.mac_address)}, - manufacturer="LIFX", - name=coordinator.parent.label, - model=products.product_map.get(self.bulb.product, "LIFX Bulb"), - sw_version=self.bulb.host_firmware_version, - suggested_area=self.bulb.group, - ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index eb62cb8016e..dd4e50d8f16 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -206,61 +206,60 @@ class LIFXLight(LIFXEntity, LightEntity): async def set_state(self, **kwargs: Any) -> None: """Set a color on the light and turn it on/off.""" self.coordinator.async_set_updated_data(None) - async with self.coordinator.lock: - # Cancel any pending refreshes - bulb = self.bulb + # Cancel any pending refreshes + bulb = self.bulb - await self.effects_conductor.stop([bulb]) + await self.effects_conductor.stop([bulb]) - if ATTR_EFFECT in kwargs: - await self.default_effect(**kwargs) - return + if ATTR_EFFECT in kwargs: + await self.default_effect(**kwargs) + return - if ATTR_INFRARED in kwargs: - infrared_entity_id = self.coordinator.async_get_entity_id( - Platform.SELECT, INFRARED_BRIGHTNESS - ) - _LOGGER.warning( - ( - "The 'infrared' attribute of 'lifx.set_state' is deprecated:" - " call 'select.select_option' targeting '%s' instead" - ), - infrared_entity_id, - ) - bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) + if ATTR_INFRARED in kwargs: + infrared_entity_id = self.coordinator.async_get_entity_id( + Platform.SELECT, INFRARED_BRIGHTNESS + ) + _LOGGER.warning( + ( + "The 'infrared' attribute of 'lifx.set_state' is deprecated:" + " call 'select.select_option' targeting '%s' instead" + ), + infrared_entity_id, + ) + bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) - if ATTR_TRANSITION in kwargs: - fade = int(kwargs[ATTR_TRANSITION] * 1000) - else: - fade = 0 + if ATTR_TRANSITION in kwargs: + fade = int(kwargs[ATTR_TRANSITION] * 1000) + else: + fade = 0 - # These are both False if ATTR_POWER is not set - power_on = kwargs.get(ATTR_POWER, False) - power_off = not kwargs.get(ATTR_POWER, True) + # These are both False if ATTR_POWER is not set + power_on = kwargs.get(ATTR_POWER, False) + power_off = not kwargs.get(ATTR_POWER, True) - hsbk = find_hsbk(self.hass, **kwargs) + hsbk = find_hsbk(self.hass, **kwargs) - if not self.is_on: - if power_off: - await self.set_power(False) - # If fading on with color, set color immediately - if hsbk and power_on: - await self.set_color(hsbk, kwargs) - await self.set_power(True, duration=fade) - elif hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - elif power_on: - await self.set_power(True, duration=fade) - else: - if power_on: - await self.set_power(True) - if hsbk: - await self.set_color(hsbk, kwargs, duration=fade) - if power_off: - await self.set_power(False, duration=fade) + if not self.is_on: + if power_off: + await self.set_power(False) + # If fading on with color, set color immediately + if hsbk and power_on: + await self.set_color(hsbk, kwargs) + await self.set_power(True, duration=fade) + elif hsbk: + await self.set_color(hsbk, kwargs, duration=fade) + elif power_on: + await self.set_power(True, duration=fade) + else: + if power_on: + await self.set_power(True) + if hsbk: + await self.set_color(hsbk, kwargs, duration=fade) + if power_off: + await self.set_power(False, duration=fade) - # Avoid state ping-pong by holding off updates as the state settles - await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) + # Avoid state ping-pong by holding off updates as the state settles + await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) # Update when the transition starts and ends await self.update_during_transition(fade) @@ -274,9 +273,7 @@ class LIFXLight(LIFXEntity, LightEntity): "This device does not support setting HEV cycle state" ) - await self.coordinator.sensor_coordinator.async_set_hev_cycle_state( - power, duration or 0 - ) + await self.coordinator.async_set_hev_cycle_state(power, duration or 0) await self.update_during_transition(duration or 0) async def set_power( diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 7f715a0d49b..e867bb65eb0 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,7 +1,7 @@ { "domain": "lifx", "name": "LIFX", - "codeowners": ["@bdraco", "@Djelibeybi"], + "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 1b58ea04686..9ad457e0270 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -15,8 +15,8 @@ from .const import ( INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP, ) -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity from .util import lifx_features THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] @@ -42,39 +42,33 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities: list[LIFXSensorEntity] = [] + entities: list[LIFXEntity] = [] if lifx_features(coordinator.device)["infrared"]: entities.append( - LIFXInfraredBrightnessSelectEntity( - coordinator.sensor_coordinator, description=INFRARED_BRIGHTNESS_ENTITY - ) + LIFXInfraredBrightnessSelectEntity(coordinator, INFRARED_BRIGHTNESS_ENTITY) ) if lifx_features(coordinator.device)["multizone"] is True: - entities.append( - LIFXThemeSelectEntity( - coordinator.sensor_coordinator, description=THEME_ENTITY - ) - ) + entities.append(LIFXThemeSelectEntity(coordinator, THEME_ENTITY)) async_add_entities(entities) -class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the IR brightness config entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = coordinator.current_infrared_brightness @callback @@ -93,21 +87,21 @@ class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): await self.coordinator.async_set_infrared_brightness(option) -class LIFXThemeSelectEntity(LIFXSensorEntity, SelectEntity): +class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): """Theme entity for LIFX multizone devices.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription, ) -> None: """Initialise the theme selection entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_current_option = None @callback diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index da03b33f52a..654b5285756 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_RSSI, DOMAIN -from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator -from .entity import LIFXSensorEntity +from .coordinator import LIFXUpdateCoordinator +from .entity import LIFXEntity SCAN_INTERVAL = timedelta(seconds=30) @@ -35,24 +35,24 @@ async def async_setup_entry( ) -> None: """Set up LIFX sensor from config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LIFXRssiSensor(coordinator.sensor_coordinator, RSSI_SENSOR)]) + async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)]) -class LIFXRssiSensor(LIFXSensorEntity, SensorEntity): +class LIFXRssiSensor(LIFXEntity, SensorEntity): """LIFX RSSI sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXSensorUpdateCoordinator, + coordinator: LIFXUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialise the RSSI sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" self._attr_native_unit_of_measurement = coordinator.rssi_uom @callback diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 67190aaa599..feaeba8da8f 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from functools import partial from typing import Any from aiolifx import products from aiolifx.aiolifx import Light from aiolifx.message import Message -import async_timeout from awesomeversion import AwesomeVersion from homeassistant.components.light import ( @@ -28,7 +28,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util -from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT +from .const import ( + _LOGGER, + DEFAULT_ATTEMPTS, + DOMAIN, + INFRARED_BRIGHTNESS_VALUES_MAP, + OVERALL_TIMEOUT, +) FIX_MAC_FW = AwesomeVersion("3.70") @@ -177,21 +183,61 @@ def mac_matches_serial_number(mac_addr: str, serial_number: str) -> bool: async def async_execute_lifx(method: Callable) -> Message: - """Execute a lifx coroutine and wait for a response.""" - future: asyncio.Future[Message] = asyncio.Future() + """Execute a lifx callback method and wait for a response.""" + return ( + await async_multi_execute_lifx_with_retries( + [method], DEFAULT_ATTEMPTS, OVERALL_TIMEOUT + ) + )[0] - def _callback(bulb: Light, message: Message) -> None: - if not future.done(): - # The future will get canceled out from under - # us by async_timeout when we hit the OVERALL_TIMEOUT + +async def async_multi_execute_lifx_with_retries( + methods: list[Callable], attempts: int, overall_timeout: int +) -> list[Message]: + """Execute multiple lifx callback methods with retries and wait for a response. + + This functional will the overall timeout by the number of attempts and + wait for each method to return a result. If we don't get a result + within the split timeout, we will send all methods that did not generate + a response again. + + If we don't get a result after all attempts, we will raise an + asyncio.TimeoutError exception. + """ + loop = asyncio.get_running_loop() + futures: list[asyncio.Future] = [loop.create_future() for _ in methods] + + def _callback( + bulb: Light, message: Message | None, future: asyncio.Future[Message] + ) -> None: + if message and not future.done(): future.set_result(message) - method(callb=_callback) - result = None + timeout_per_attempt = overall_timeout / attempts - async with async_timeout.timeout(OVERALL_TIMEOUT): - result = await future + for _ in range(attempts): + for idx, method in enumerate(methods): + future = futures[idx] + if not future.done(): + method(callb=partial(_callback, future=future)) - if result is None: - raise asyncio.TimeoutError("No response from LIFX bulb") - return result + _, pending = await asyncio.wait(futures, timeout=timeout_per_attempt) + if not pending: + break + + results: list[Message] = [] + failed: list[str] = [] + for idx, future in enumerate(futures): + if not future.done() or not (result := future.result()): + method = methods[idx] + failed.append(str(getattr(method, "__name__", method))) + else: + results.append(result) + + if failed: + failed_methods = ", ".join(failed) + raise asyncio.TimeoutError( + f"{failed_methods} timed out after {attempts} attempts" + ) + + return results diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 423be8143b8..c8807d40cc1 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -129,7 +129,7 @@ class LocalCalendarEntity(CalendarEntity): recurrence_range=range_value, ) except EventStoreError as err: - raise HomeAssistantError("Error while deleting event: {err}") from err + raise HomeAssistantError(f"Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -153,7 +153,7 @@ class LocalCalendarEntity(CalendarEntity): recurrence_range=range_value, ) except EventStoreError as err: - raise HomeAssistantError("Error while updating event: {err}") from err + raise HomeAssistantError(f"Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 5e371f29ab8..740d107d625 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,43 +1,5 @@ # Describes the format for available lock services -clear_usercode: - name: Clear usercode - description: Clear a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to clear code from. - selector: - number: - min: 1 - max: 255 - -get_usercode: - name: Get usercode - description: Retrieve a usercode from lock. - fields: - node_id: - name: Node ID - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - name: Code slot - description: Code slot to retrieve a code from. - selector: - number: - min: 1 - max: 255 - lock: name: Lock description: Lock all or specified locks. @@ -66,29 +28,6 @@ open: selector: text: -set_usercode: - name: Set usercode - description: Set a usercode to lock. - fields: - node_id: - description: Node id of the lock. - selector: - number: - min: 1 - max: 255 - code_slot: - description: Code slot to set the code. - selector: - number: - min: 1 - max: 255 - usercode: - description: Code to set. - required: true - example: 1234 - selector: - text: - unlock: name: Unlock description: Unlock all or specified locks. diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index ab073f296f7..86dcfdf82c5 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -22,7 +22,7 @@ from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes -@dataclass +@dataclass(slots=True) class LogbookConfig: """Configuration for the logbook integration.""" @@ -64,12 +64,14 @@ class LazyEventPartialState: self.context_id_bin: bytes | None = self.row.context_id_bin self.context_user_id_bin: bytes | None = self.row.context_user_id_bin self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin - if data := getattr(row, "data", None): + # We need to explicitly check for the row is EventAsRow as the unhappy path + # to fetch row.data for Row is very expensive + if type(row) is EventAsRow: # pylint: disable=unidiomatic-typecheck # If its an EventAsRow we can avoid the whole # json decode process as we already have the data - self.data = data + self.data = row.data return - source = cast(str, self.row.shared_data or self.row.event_data) + source = cast(str, self.row.event_data) if not source: self.data = {} elif event_data := self._event_data_cache.get(source): @@ -95,7 +97,7 @@ class LazyEventPartialState: return bytes_to_ulid_or_none(self.context_parent_id_bin) -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class EventAsRow: """Convert an event to a row.""" @@ -103,17 +105,14 @@ class EventAsRow: context: Context context_id_bin: bytes time_fired_ts: float - state_id: int + row_id: int event_data: str | None = None - old_format_icon: None = None - event_id: None = None entity_id: str | None = None icon: str | None = None context_user_id_bin: bytes | None = None context_parent_id_bin: bytes | None = None event_type: str | None = None state: str | None = None - shared_data: str | None = None context_only: None = None @@ -130,7 +129,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), - state_id=hash(event), + row_id=hash(event), ) # States are prefiltered so we never get states # that are missing new_state or old_state @@ -146,6 +145,6 @@ def async_event_to_row(event: Event) -> EventAsRow: context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(new_state.last_updated), - state_id=hash(event), + row_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), ) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e98358..671f8f8f1c2 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Generator, Sequence from contextlib import suppress from dataclasses import dataclass from datetime import datetime as dt +import logging from typing import Any from sqlalchemy.engine import Result @@ -14,11 +15,15 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, + extract_event_type_ids, extract_metadata_ids, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.util import ( + execute_stmt_lambda_element, + session_scope, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_DOMAIN, @@ -60,12 +65,14 @@ from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_even from .queries import statement_for_request from .queries.common import PSEUDO_EVENT_STATE_CHANGED +_LOGGER = logging.getLogger(__name__) -@dataclass + +@dataclass(slots=True) class LogbookRun: """A logbook run which may be a long running event stream or single request.""" - context_lookup: ContextLookup + context_lookup: dict[bytes | None, Row | EventAsRow | None] external_events: dict[ str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] @@ -73,6 +80,7 @@ class LogbookRun: entity_name_cache: EntityNameCache include_entity_name: bool format_time: Callable[[Row | EventAsRow], Any] + memoize_new_contexts: bool = True class EventProcessor: @@ -104,7 +112,7 @@ class EventProcessor: _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) self.logbook_run = LogbookRun( - context_lookup=ContextLookup(hass), + context_lookup={None: None}, external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), @@ -125,6 +133,7 @@ class EventProcessor: """ self.logbook_run.event_cache.clear() self.logbook_run.context_lookup.clear() + self.logbook_run.memoize_new_contexts = False def get_events( self, @@ -132,45 +141,33 @@ class EventProcessor: end_day: dt, ) -> list[dict[str, Any]]: """Get events for a period of time.""" - - def yield_rows(result: Result) -> Sequence[Row] | Result: - """Yield rows from the database.""" - # end_day - start_day intentionally checks .days and not .total_seconds() - # since we don't want to switch over to buffered if they go - # over one day by a few hours since the UI makes it so easy to do that. - if self.limited_select or (end_day - start_day).days <= 1: - return result.all() - # Only buffer rows to reduce memory pressure - # if we expect the result set is going to be very large. - # What is considered very large is going to differ - # based on the hardware Home Assistant is running on. - # - # sqlalchemy suggests that is at least 10k, but for - # even and RPi3 that number seems higher in testing - # so we don't switch over until we request > 1 day+ of data. - # - return result.yield_per(1024) - with session_scope(hass=self.hass, read_only=True) as session: metadata_ids: list[int] | None = None + instance = get_instance(self.hass) if self.entity_ids: - instance = get_instance(self.hass) metadata_ids = extract_metadata_ids( instance.states_meta_manager.get_many( self.entity_ids, session, False ) ) + event_type_ids = tuple( + extract_event_type_ids( + instance.event_type_manager.get_many(self.event_types, session) + ) + ) stmt = statement_for_request( start_day, end_day, - self.event_types, + event_type_ids, self.entity_ids, metadata_ids, self.device_ids, self.filters, self.context_id, ) - return self.humanify(yield_rows(session.execute(stmt))) + return self.humanify( + execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) def humanify( self, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result @@ -201,13 +198,18 @@ def _humanify( entity_name_cache = logbook_run.entity_name_cache include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time + memoize_new_contexts = logbook_run.memoize_new_contexts + memoize_context = context_lookup.setdefault # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin: bytes = row.context_id_bin + if memoize_new_contexts: + memoize_context(context_id_bin, row) if row.context_only: continue event_type = row.event_type + if event_type == EVENT_CALL_SERVICE: continue if event_type is PSEUDO_EVENT_STATE_CHANGED: @@ -229,18 +231,24 @@ def _humanify( } if include_entity_name: data[LOGBOOK_ENTRY_NAME] = entity_name_cache.get(entity_id) - if icon := row.icon or row.old_format_icon: + if icon := row.icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: domain, describe_event = external_events[event_type] - data = describe_event(event_cache.get(row)) + try: + data = describe_event(event_cache.get(row)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error with %s describe event for %s", domain, event_type + ) + continue data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,37 +267,10 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data -class ContextLookup: - """A lookup class for context origins.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Memorize context origin.""" - self.hass = hass - self._memorize_new = True - self._lookup: dict[bytes | None, Row | EventAsRow | None] = {None: None} - - def memorize(self, row: Row | EventAsRow) -> bytes | None: - """Memorize a context from the database.""" - if self._memorize_new: - context_id_bin: bytes = row.context_id_bin - self._lookup.setdefault(context_id_bin, row) - return context_id_bin - return None - - def clear(self) -> None: - """Clear the context origins and stop recording new ones.""" - self._lookup.clear() - self._memorize_new = False - - def get(self, context_id_bin: bytes) -> Row | EventAsRow | None: - """Get the context origin.""" - return self._lookup.get(context_id_bin) - - class ContextAugmenter: """Augment data with context trace.""" @@ -302,11 +283,13 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin is not None and ( + context_row := self.context_lookup.get(context_id_bin) + ): + return context_row if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +297,13 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): @@ -368,7 +351,11 @@ class ContextAugmenter: data[CONTEXT_EVENT_TYPE] = event_type data[CONTEXT_DOMAIN] = domain event = self.event_cache.get(context_row) - described = describe_event(event) + try: + described = describe_event(event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error with %s describe event for %s", domain, event_type) + return if name := described.get(LOGBOOK_ENTRY_NAME): data[CONTEXT_NAME] = name if message := described.get(LOGBOOK_ENTRY_MESSAGE): @@ -385,15 +372,9 @@ class ContextAugmenter: def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" - if ( - row is other_row - or (state_id := row.state_id) - and state_id == other_row.state_id - or (event_id := row.event_id) - and event_id == other_row.event_id - ): - return True - return False + return bool( + row is other_row or (row_id := row.row_id) and row_id == other_row.row_id + ) def _row_time_fired_isoformat(row: Row | EventAsRow) -> str: diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index b83f7a4428a..29d89a4c22f 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -20,7 +20,7 @@ from .entities_and_devices import entities_devices_stmt def statement_for_request( start_day_dt: dt, end_day_dt: dt, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], entity_ids: list[str] | None = None, states_metadata_ids: Collection[int] | None = None, device_ids: list[str] | None = None, @@ -37,7 +37,7 @@ def statement_for_request( return all_stmt( start_day, end_day, - event_types, + event_type_ids, filters, context_id_bin, ) @@ -52,7 +52,7 @@ def statement_for_request( return entities_devices_stmt( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids or [], [json_dumps(entity_id) for entity_id in entity_ids], [json_dumps(device_id) for device_id in device_ids], @@ -63,7 +63,7 @@ def statement_for_request( return entities_stmt( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids or [], [json_dumps(entity_id) for entity_id in entity_ids], ) @@ -73,6 +73,6 @@ def statement_for_request( return devices_stmt( start_day, end_day, - event_types, + event_type_ids, [json_dumps(device_id) for device_id in device_ids], ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 70214fbb04b..c6196687ac2 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -18,13 +18,13 @@ from .common import apply_states_filters, select_events_without_states, select_s def all_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], filters: Filters | None, context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" stmt = lambda_stmt( - lambda: select_events_without_states(start_day, end_day, event_types) + lambda: select_events_without_states(start_day, end_day, event_type_ids) ) if context_id_bin is not None: stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 08bf1b8ab9b..cbbe8724ece 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder.db_schema import ( OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, + SHARED_DATA_OR_LEGACY_EVENT_DATA, STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, @@ -23,7 +24,6 @@ from homeassistant.components.recorder.db_schema import ( StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers -from homeassistant.components.recorder.queries import select_event_type_ids from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -37,6 +37,11 @@ ALWAYS_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers(ALWAYS_CONTINUOUS_DOMAIN UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" +ICON_OR_OLD_FORMAT_ICON_JSON = sqlalchemy.case( + (SHARED_ATTRS_JSON["icon"].is_(None), OLD_FORMAT_ATTRS_JSON["icon"].as_string()), + else_=SHARED_ATTRS_JSON["icon"].as_string(), +).label("icon") + PSEUDO_EVENT_STATE_CHANGED: Final = None # Since we don't store event_types and None # and we don't store state_changed in events @@ -46,9 +51,9 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None # in the payload EVENT_COLUMNS = ( - Events.event_id.label("event_id"), + Events.event_id.label("row_id"), EventTypes.event_type.label("event_type"), - Events.event_data.label("event_data"), + SHARED_DATA_OR_LEGACY_EVENT_DATA, Events.time_fired_ts.label("time_fired_ts"), Events.context_id_bin.label("context_id_bin"), Events.context_user_id_bin.label("context_user_id_bin"), @@ -56,23 +61,19 @@ EVENT_COLUMNS = ( ) STATE_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), StatesMeta.entity_id.label("entity_id"), - SHARED_ATTRS_JSON["icon"].as_string().label("icon"), - OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), + ICON_OR_OLD_FORMAT_ICON_JSON, ) STATE_CONTEXT_ONLY_COLUMNS = ( - States.state_id.label("state_id"), States.state.label("state"), StatesMeta.entity_id.label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), - literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) EVENT_COLUMNS_FOR_STATE_SELECT = ( - literal(value=None, type_=sqlalchemy.Text).label("event_id"), + States.state_id.label("row_id"), # We use PSEUDO_EVENT_STATE_CHANGED aka None for # state_changed events since it takes up less # space in the response and every row has to be @@ -85,21 +86,17 @@ EVENT_COLUMNS_FOR_STATE_SELECT = ( States.context_id_bin.label("context_id_bin"), States.context_user_id_bin.label("context_user_id_bin"), States.context_parent_id_bin.label("context_parent_id_bin"), - literal(value=None, type_=sqlalchemy.Text).label("shared_data"), ) EMPTY_STATE_COLUMNS = ( - literal(value=0, type_=sqlalchemy.Integer).label("state_id"), literal(value=None, type_=sqlalchemy.String).label("state"), literal(value=None, type_=sqlalchemy.String).label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), - literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) EVENT_ROWS_NO_STATES = ( *EVENT_COLUMNS, - EventData.shared_data.label("shared_data"), *EMPTY_STATE_COLUMNS, ) @@ -112,13 +109,13 @@ NOT_CONTEXT_ONLY = literal(value=None, type_=sqlalchemy.String).label("context_o def select_events_context_id_subquery( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], ) -> Select: """Generate the select for a context_id subquery.""" return ( select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .where(Events.event_type_id.in_(event_type_ids)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -145,13 +142,13 @@ def select_states_context_only() -> Select: def select_events_without_states( - start_day: float, end_day: float, event_types: tuple[str, ...] + start_day: float, end_day: float, event_type_ids: tuple[int, ...] ) -> Select: """Generate an events select that does not join states.""" return ( select(*EVENT_ROWS_NO_STATES, NOT_CONTEXT_ONLY) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .where(Events.event_type_id.in_(event_type_ids)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index a5c06dc84cf..75604de6104 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -31,12 +31,12 @@ from .common import ( def _select_device_id_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple devices.""" inner = ( - select_events_context_id_subquery(start_day, end_day, event_types) + select_events_context_id_subquery(start_day, end_day, event_type_ids) .where(apply_event_device_id_matchers(json_quotable_device_ids)) .subquery() ) @@ -47,14 +47,14 @@ def _apply_devices_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the device context ids and a query to find linked row.""" devices_cte: CTE = _select_device_id_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, json_quotable_device_ids, ).cte() return sel.union_all( @@ -77,18 +77,18 @@ def _apply_devices_context_union( def devices_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], json_quotable_device_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple devices.""" stmt = lambda_stmt( lambda: _apply_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( apply_event_device_id_matchers(json_quotable_device_ids) ), start_day, end_day, - event_types, + event_type_ids, json_quotable_device_ids, ).order_by(Events.time_fired_ts) ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index ebb56befa50..95c1d565263 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -35,13 +35,13 @@ from .common import ( def _select_entities_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities.""" union = union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( + select_events_context_id_subquery(start_day, end_day, event_type_ids).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), apply_entities_hints(select(States.context_id_bin)) @@ -57,7 +57,7 @@ def _apply_entities_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: @@ -65,7 +65,7 @@ def _apply_entities_context_union( entities_cte: CTE = _select_entities_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, ).cte() @@ -95,19 +95,19 @@ def _apply_entities_context_union( def entities_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" return lambda_stmt( lambda: _apply_entities_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, ).order_by(Events.time_fired_ts) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index f7ffde4f81a..c465a343d61 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -35,14 +35,14 @@ from .entities import ( def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities and multiple devices.""" union = union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( + select_events_context_id_subquery(start_day, end_day, event_type_ids).where( _apply_event_entity_id_device_id_matchers( json_quoted_entity_ids, json_quoted_device_ids ) @@ -60,7 +60,7 @@ def _apply_entities_devices_context_union( sel: Select, start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], @@ -68,7 +68,7 @@ def _apply_entities_devices_context_union( devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, @@ -103,7 +103,7 @@ def _apply_entities_devices_context_union( def entities_devices_stmt( start_day: float, end_day: float, - event_types: tuple[str, ...], + event_type_ids: tuple[int, ...], states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], @@ -111,14 +111,14 @@ def entities_devices_stmt( """Generate a logbook query for multiple entities.""" stmt = lambda_stmt( lambda: _apply_entities_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( + select_events_without_states(start_day, end_day, event_type_ids).where( _apply_event_entity_id_device_id_matchers( json_quoted_entity_ids, json_quoted_device_ids ) ), start_day, end_day, - event_types, + event_type_ids, states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 6d24285ba11..c4e6b9814f4 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -39,7 +39,7 @@ BIG_QUERY_RECENT_HOURS = 24 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class LogbookLiveStream: """Track a logbook live stream.""" @@ -221,8 +221,6 @@ async def _async_events_consumer( event_processor: EventProcessor, ) -> None: """Stream events from the queue.""" - event_processor.switch_to_live() - while True: events: list[Event] = [await stream_queue.get()] # If the event is older than the last db @@ -430,6 +428,7 @@ async def ws_event_stream( event_processor, partial=False, ) + event_processor.switch_to_live() def _ws_formatted_get_events( diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index df275eaae93..0f1751c1b2e 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -77,7 +77,7 @@ async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: return loggers -@dataclass +@dataclass(slots=True) class LoggerSetting: """Settings for a single module or integration.""" @@ -86,7 +86,7 @@ class LoggerSetting: type: str -@dataclass +@dataclass(slots=True) class LoggerDomainConfig: """Logger domain config.""" diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a109..8217b3913a8 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f880f83d766..1412aa085c8 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -119,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: resource_collection = resources.ResourceStorageCollection(hass, default_config) - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( resource_collection, "lovelace/resources", "resource", @@ -198,7 +198,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: dashboards_collection.async_add_listener(storage_dashboard_changed) await dashboards_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( dashboards_collection, "lovelace/dashboards", "dashboard", diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index ef47ea0b1fc..054aaf9b24c 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -6,7 +6,6 @@ import logging import os from pathlib import Path import time -from typing import cast import voluptuous as vol @@ -218,7 +217,7 @@ def _config_info(mode, config): } -class DashboardsCollection(collection.StorageCollection): +class DashboardsCollection(collection.DictStorageCollection): """Collection of dashboards.""" CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) @@ -228,13 +227,12 @@ class DashboardsCollection(collection.StorageCollection): """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), - _LOGGER, ) - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (data := await self.store.async_load()) is None: - return cast(dict | None, data) + return data updated = False @@ -246,7 +244,7 @@ class DashboardsCollection(collection.StorageCollection): if updated: await self.store.async_save(data) - return cast(dict | None, data) + return data async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" @@ -263,10 +261,10 @@ class DashboardsCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_URL_PATH] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - updated = {**data, **update_data} + updated = {**item, **update_data} if CONF_ICON in updated and updated[CONF_ICON] is None: updated.pop(CONF_ICON) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index e6c4acfdf69..b6d0c939fec 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any import uuid import voluptuous as vol @@ -45,7 +45,7 @@ class ResourceYAMLCollection: return self.data -class ResourceStorageCollection(collection.StorageCollection): +class ResourceStorageCollection(collection.DictStorageCollection): """Collection to store resources.""" loaded = False @@ -56,7 +56,6 @@ class ResourceStorageCollection(collection.StorageCollection): """Initialize the storage collection.""" super().__init__( storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), - _LOGGER, ) self.ll_config = ll_config @@ -68,10 +67,10 @@ class ResourceStorageCollection(collection.StorageCollection): return {"resources": len(self.async_items() or [])} - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" - if (data := await self.store.async_load()) is not None: - return cast(dict | None, data) + if (store_data := await self.store.async_load()) is not None: + return store_data # Import it from config. try: @@ -83,20 +82,20 @@ class ResourceStorageCollection(collection.StorageCollection): return None # Remove it from config and save both resources + config - data = conf[CONF_RESOURCES] + resources: list[dict[str, Any]] = conf[CONF_RESOURCES] try: - vol.Schema([RESOURCE_SCHEMA])(data) + vol.Schema([RESOURCE_SCHEMA])(resources) except vol.Invalid as err: _LOGGER.warning("Resource import failed. Data invalid: %s", err) return None conf.pop(CONF_RESOURCES) - for item in data: + for item in resources: item[CONF_ID] = uuid.uuid4().hex - data = {"items": data} + data: collection.SerializedStorageCollection = {"items": resources} await self.store.async_save(data) await self.ll_config.async_save(conf) @@ -114,7 +113,7 @@ class ResourceStorageCollection(collection.StorageCollection): """Return unique ID.""" return uuid.uuid4().hex - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" if not self.loaded: await self.async_load() @@ -124,4 +123,4 @@ class ResourceStorageCollection(collection.StorageCollection): if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) - return {**data, **update_data} + return {**item, **update_data} diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index c5d05fd1748..2412aaad0a1 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/luci", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], - "requirements": ["openwrt-luci-rpc==1.1.11"] + "requirements": ["openwrt-luci-rpc==1.1.16"] } diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index f8ca93beb2e..7d33a822087 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -107,8 +107,5 @@ class LutronLed(LutronDevice, SwitchEntity): def update(self) -> None: """Call when forcing a refresh of the device.""" - if self._lutron_device.last_state is not None: - return - # The following property getter actually triggers an update in Lutron self._lutron_device.state # pylint: disable=pointless-statement diff --git a/homeassistant/components/lutron_caseta/logbook.py b/homeassistant/components/lutron_caseta/logbook.py index 18c46405ed0..ec612ded375 100644 --- a/homeassistant/components/lutron_caseta/logbook.py +++ b/homeassistant/components/lutron_caseta/logbook.py @@ -38,11 +38,15 @@ def async_describe_events( device_type = data[ATTR_TYPE] leap_button_number = data[ATTR_LEAP_BUTTON_NUMBER] dr_device_id = data[ATTR_DEVICE_ID] - lutron_data = get_lutron_data_by_dr_id(hass, dr_device_id) - keypad = lutron_data.keypad_data.dr_device_id_to_keypad.get(dr_device_id) - keypad_id = keypad["lutron_device_id"] + rev_button_map: dict[int, str] | None = None + keypad_button_names_to_leap: dict[int, dict[str, int]] = {} + keypad_id: int = -1 - keypad_button_names_to_leap = lutron_data.keypad_data.button_names_to_leap + if lutron_data := get_lutron_data_by_dr_id(hass, dr_device_id): + keypad_data = lutron_data.keypad_data + keypad = keypad_data.dr_device_id_to_keypad.get(dr_device_id) + keypad_id = keypad["lutron_device_id"] + keypad_button_names_to_leap = keypad_data.button_names_to_leap if not (rev_button_map := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)): if fwd_button_map := keypad_button_names_to_leap.get(keypad_id): diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index f97b2c5337b..29f023d0de2 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -86,6 +86,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = EntityComponent[MailboxEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) + component.register_shutdown() await component.async_add_entities([mailbox_entity]) setup_tasks = [ diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index fd6adb009aa..adb251bd71a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -187,13 +187,19 @@ PLATFORM_SCHEMA = vol.Schema( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the manual MQTT alarm platform.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the alarm_control_panel platform setup + # also will be triggered when mqtt is loading the `alarm_control_panel` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return add_entities( [ ManualMQTTAlarm( diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index e86e5c0ca49..4c47cd4d235 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -195,6 +195,17 @@ async def async_remove_config_entry_device( if node is None: return True + if node.is_bridge_device: + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + for device in devices: + if device.via_device_id == device_entry.id: + device_registry.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) + matter = get_matter(hass) await matter.matter_client.remove_node(node.node_id) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py new file mode 100644 index 00000000000..4e227d83b44 --- /dev/null +++ b/homeassistant/components/matter/cover.py @@ -0,0 +1,157 @@ +"""Matter cover.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +# The MASK used for extracting bits 0 to 1 of the byte. +OPERATIONAL_STATUS_MASK = 0b11 + +# map Matter window cover types to HA device class +TYPE_MAP = { + clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, + clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, +} + + +class OperationalStatus(IntEnum): + """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + + COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 + COVERING_IS_CURRENTLY_OPENING = 0b01 + COVERING_IS_CURRENTLY_CLOSING = 0b10 + RESERVED = 0b11 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Cover from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.COVER, async_add_entities) + + +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 + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover movement.""" + await self.send_device_command(clusters.WindowCovering.Commands.StopMotion()) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.UpOrOpen()) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.DownOrClose()) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + await self.send_device_command( + # value needs to be inverted and is sent in 100ths + clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100) + ) + + async def send_device_command(self, command: Any) -> None: + """Send device command.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + operational_status = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.OperationalStatus + ) + + assert operational_status is not None + + LOGGER.debug( + "Operational status %s for %s", + f"{operational_status:#010b}", + self.entity_id, + ) + + state = operational_status & OPERATIONAL_STATUS_MASK + match state: + case OperationalStatus.COVERING_IS_CURRENTLY_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + case OperationalStatus.COVERING_IS_CURRENTLY_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + case _: + 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 + + LOGGER.debug( + "Current position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_position, + self.current_cover_position, + ) + + # map matter type to HA deviceclass + device_type: clusters.WindowCovering.Enums.Type = ( + self.get_matter_attribute_value(clusters.WindowCovering.Attributes.Type) + ) + self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCover"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.OperationalStatus, + ), + ) +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 9df4484e00d..28f5b6b7f90 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -18,6 +19,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.COVER: COVER_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 3bf77daf691..cbe71447a3f 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX -@dataclass +@dataclass(slots=True) class PlayMedia: """Represents a playable media.""" @@ -36,7 +36,7 @@ class BrowseMediaSource(BrowseMedia): self.identifier = identifier -@dataclass +@dataclass(slots=True) class MediaSourceItem: """A parsed media item.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf32..a6dcb23cc47 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d17..1cab9c9099f 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa7..cce35731c72 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bbe..e1a530eef97 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ class MeteoFranceWeather( } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d0..11346ab18f9 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/monessen/__init__.py b/homeassistant/components/monessen/__init__.py new file mode 100644 index 00000000000..6ae0f37d2cc --- /dev/null +++ b/homeassistant/components/monessen/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for Monessen Fireplace.""" diff --git a/homeassistant/components/monessen/manifest.json b/homeassistant/components/monessen/manifest.json new file mode 100644 index 00000000000..26eef41cfd4 --- /dev/null +++ b/homeassistant/components/monessen/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "monessen", + "name": "Monessen", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index 742a7ec59a8..f92fa11cd77 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -4,10 +4,10 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "admin_username": "Admin [%key:common::config_flow::data::username%]", - "admin_password": "Admin [%key:common::config_flow::data::password%]", - "surveillance_username": "Surveillance [%key:common::config_flow::data::username%]", - "surveillance_password": "Surveillance [%key:common::config_flow::data::password%]" + "admin_username": "Admin username", + "admin_password": "Admin password", + "surveillance_username": "Surveillance username", + "surveillance_password": "Surveillance password" } }, "hassio_confirm": { diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 24dc4b67cd9..d3806044fcc 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -45,11 +45,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config_integration import ( - CONFIG_SCHEMA_ENTRY, - DEFAULT_VALUES, - PLATFORM_CONFIG_SCHEMA_BASE, -) +from .config_integration import PLATFORM_CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, @@ -72,7 +68,10 @@ from .const import ( # noqa: F401 CONF_WS_HEADERS, CONF_WS_PATH, DATA_MQTT, + DATA_MQTT_AVAILABLE, + DEFAULT_DISCOVERY, DEFAULT_ENCODING, + DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, @@ -83,13 +82,15 @@ from .const import ( # noqa: F401 ) from .models import ( # noqa: F401 MqttCommandTemplate, + MqttData, MqttValueTemplate, PublishPayloadType, ReceiveMessage, ReceivePayloadType, ) -from .util import ( +from .util import ( # noqa: F401 async_create_certificate_temp_files, + async_wait_for_mqtt_client, get_mqtt_data, mqtt_config_entry_enabled, valid_publish_topic, @@ -102,8 +103,6 @@ _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -MANDATORY_DEFAULT_VALUES = (CONF_PORT, CONF_DISCOVERY_PREFIX) - ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" @@ -176,67 +175,6 @@ MQTT_PUBLISH_SCHEMA = vol.All( ) -async def _async_setup_discovery( - hass: HomeAssistant, conf: ConfigType, config_entry: ConfigEntry -) -> None: - """Try to start the discovery of MQTT devices. - - This method is a coroutine. - """ - await discovery.async_start(hass, conf[CONF_DISCOVERY_PREFIX], config_entry) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MQTT protocol service.""" - websocket_api.async_register_command(hass, websocket_subscribe) - websocket_api.async_register_command(hass, websocket_mqtt_info) - return True - - -def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove unknown keys from config entry data. - - Extra keys may have been added when importing MQTT yaml configuration. - """ - filtered_data = { - k: entry.data[k] for k in CONFIG_ENTRY_CONFIG_KEYS if k in entry.data - } - if entry.data.keys() != filtered_data.keys(): - _LOGGER.warning( - ( - "The following unsupported configuration options were removed from the " - "MQTT config entry: %s" - ), - entry.data.keys() - filtered_data.keys(), - ) - hass.config_entries.async_update_entry(entry, data=filtered_data) - - -async def _async_auto_mend_config( - hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] -) -> None: - """Mends config fetched from config entry and adds missing values. - - This mends incomplete migration from old version of HA Core. - """ - entry_updated = False - entry_config = {**entry.data} - for key in MANDATORY_DEFAULT_VALUES: - if key not in entry_config: - entry_config[key] = DEFAULT_VALUES[key] - entry_updated = True - - if entry_updated: - hass.config_entries.async_update_entry(entry, data=entry_config) - - -def _merge_extended_config(entry: ConfigEntry, conf: ConfigType) -> dict[str, Any]: - """Merge advanced options in configuration.yaml config with config entry.""" - # Add default values - conf = {**DEFAULT_VALUES, **conf} - return {**conf, **entry.data} - - async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle signals of config entry being updated. @@ -245,56 +183,56 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_fetch_config( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config.""" - mqtt_data = get_mqtt_data(hass) - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) - - # Remove unknown keys from config entry data - _filter_entry_config(hass, entry) - - # Add missing defaults to migrate older config entries - await _async_auto_mend_config(hass, entry, mqtt_data.config or {}) - # Bail out if broker setting is missing - if CONF_BROKER not in entry.data: - _LOGGER.error("MQTT broker is not configured, please configure it") - return None - - # If user doesn't have configuration.yaml config, generate default values - # for options not in config entry data - if (conf := mqtt_data.config) is None: - conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - - # Merge advanced configuration values from configuration.yaml - conf = _merge_extended_config(entry, conf) - return conf - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - mqtt_data = get_mqtt_data(hass, True) + conf: dict[str, Any] + mqtt_data: MqttData - # Fetch configuration and add missing defaults for basic options - if (conf := await async_fetch_config(hass, entry)) is None: - # Bail out - return False + async def _setup_client() -> tuple[MqttData, dict[str, Any]]: + """Set up the MQTT client.""" + # Fetch configuration + conf = dict(entry.data) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + await async_create_certificate_temp_files(hass, conf) + client = MQTT(hass, entry, conf) + if DOMAIN in hass.data: + mqtt_data = get_mqtt_data(hass) + mqtt_data.config = mqtt_yaml + mqtt_data.client = client + else: + # Initial setup + websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_mqtt_info) + hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) + client.start(mqtt_data) - await async_create_certificate_temp_files(hass, dict(entry.data)) - mqtt_data.client = MQTT(hass, entry, conf) - # Restore saved subscriptions - if mqtt_data.subscriptions_to_restore: - mqtt_data.client.async_restore_tracked_subscriptions( - mqtt_data.subscriptions_to_restore + # Restore saved subscriptions + if mqtt_data.subscriptions_to_restore: + mqtt_data.client.async_restore_tracked_subscriptions( + mqtt_data.subscriptions_to_restore + ) + mqtt_data.subscriptions_to_restore = [] + mqtt_data.reload_dispatchers.append( + entry.add_update_listener(_async_config_entry_updated) ) - mqtt_data.subscriptions_to_restore = [] - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) - await mqtt_data.client.async_connect() + await mqtt_data.client.async_connect() + return (mqtt_data, conf) + + client_available: asyncio.Future[bool] + if DATA_MQTT_AVAILABLE not in hass.data: + client_available = hass.data[DATA_MQTT_AVAILABLE] = asyncio.Future() + else: + client_available = hass.data[DATA_MQTT_AVAILABLE] + + setup_ok: bool = False + try: + mqtt_data, conf = await _setup_client() + setup_ok = True + finally: + if not client_available.done(): + client_available.set_result(setup_ok) async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" @@ -349,7 +287,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return - assert mqtt_data.client is not None and msg_topic is not None + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( @@ -455,8 +393,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) # Setup discovery - if conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, conf, entry) + if conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): + await discovery.async_start( + hass, conf.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX), entry + ) # 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 @@ -585,7 +525,6 @@ def async_subscribe_connection_status( def is_connected(hass: HomeAssistant) -> bool: """Return if MQTT client is connected.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None return mqtt_data.client.connected @@ -603,7 +542,6 @@ async def async_remove_config_entry_device( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload MQTT dump and publish service when the config entry is unloaded.""" mqtt_data = get_mqtt_data(hass) - assert mqtt_data.client is not None mqtt_client = mqtt_data.client # Unload publish and dump services. @@ -649,6 +587,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registry_hooks.popitem()[1]() # Wait for all ACKs and stop the loop await mqtt_client.async_disconnect() + + # Cleanup MQTT client availability + hass.data.pop(DATA_MQTT_AVAILABLE, None) # Store remaining subscriptions to be able to restore or reload them # when the entry is set up again if subscriptions := mqtt_client.subscriptions: diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc1f86d285a..de593385c1f 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -50,6 +50,10 @@ ABBREVIATIONS = { "curr_temp_tpl": "current_temperature_template", "dev": "device", "dev_cla": "device_class", + "dir_cmd_t": "direction_command_topic", + "dir_cmd_tpl": "direction_command_template", + "dir_stat_t": "direction_state_topic", + "dir_val_tpl": "direction_value_template", "dock_t": "docked_topic", "dock_tpl": "docked_template", "e": "encoding", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 5585a6cee5f..cd73ee8efb6 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable from functools import lru_cache -import inspect from itertools import chain, groupby import logging from operator import attrgetter @@ -44,7 +43,6 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception from .const import ( - ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE, @@ -56,10 +54,16 @@ from .const import ( CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + DEFAULT_BIRTH, DEFAULT_ENCODING, + DEFAULT_KEEPALIVE, + DEFAULT_PORT, DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_TRANSPORT, + DEFAULT_WILL, + DEFAULT_WS_HEADERS, + DEFAULT_WS_PATH, MQTT_CONNECTED, MQTT_DISCONNECTED, PROTOCOL_5, @@ -69,6 +73,7 @@ from .const import ( from .models import ( AsyncMessageCallbackType, MessageCallbackType, + MqttData, PublishMessage, PublishPayloadType, ReceiveMessage, @@ -111,11 +116,11 @@ async def async_publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot publish to topic '{topic}', MQTT is not enabled" ) + mqtt_data = get_mqtt_data(hass) outgoing_payload = payload if not isinstance(payload, bytes): if not encoding: @@ -161,30 +166,11 @@ async def async_subscribe( Call the return value to unsubscribe. """ - mqtt_data = get_mqtt_data(hass, True) - if mqtt_data.client is None or not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled" ) - # Support for a deprecated callback type was removed with HA core 2023.3.0 - # The signature validation code can be removed from HA core 2023.5.0 - non_default = 0 - if msg_callback: - non_default = sum( - p.default == inspect.Parameter.empty - for _, p in inspect.signature(msg_callback).parameters.items() - ) - - # Check for not supported callback signatures - # Can be removed from HA core 2023.5.0 - if non_default != 1: - module = inspect.getmodule(msg_callback) - raise HomeAssistantError( - "Signature for MQTT msg_callback '{}.{}' is not supported".format( - module.__name__ if module else "", msg_callback.__name__ - ) - ) - + mqtt_data = get_mqtt_data(hass) async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( @@ -272,8 +258,8 @@ class MqttClientSetup: client_cert = get_file_path(CONF_CLIENT_CERT, config.get(CONF_CLIENT_CERT)) tls_insecure = config.get(CONF_TLS_INSECURE) if transport == TRANSPORT_WEBSOCKETS: - ws_path: str = config[CONF_WS_PATH] - ws_headers: dict[str, str] = config[CONF_WS_HEADERS] + ws_path: str = config.get(CONF_WS_PATH, DEFAULT_WS_PATH) + ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, DEFAULT_WS_HEADERS) self._client.ws_set_options(ws_path, ws_headers) if certificate is not None: self._client.tls_set( @@ -377,19 +363,16 @@ class MQTT: _mqttc: mqtt.Client _last_subscribe: float + _mqtt_data: MqttData def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - conf: ConfigType, + self, hass: HomeAssistant, config_entry: ConfigEntry, conf: ConfigType ) -> None: """Initialize Home Assistant MQTT client.""" - self._mqtt_data = get_mqtt_data(hass) - self.hass = hass self.config_entry = config_entry self.conf = conf + self._simple_subscriptions: dict[str, list[Subscription]] = {} self._wildcard_subscriptions: list[Subscription] = [] self.connected = False @@ -415,8 +398,6 @@ class MQTT: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - self.init_client() - async def async_stop_mqtt(_event: Event) -> None: """Stop MQTT component.""" await self.async_disconnect() @@ -425,6 +406,14 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) + def start( + self, + mqtt_data: MqttData, + ) -> None: + """Start Home Assistant MQTT client.""" + self._mqtt_data = mqtt_data + self.init_client() + @property def subscriptions(self) -> list[Subscription]: """Return the tracked subscriptions.""" @@ -448,15 +437,8 @@ class MQTT: self._mqttc.on_subscribe = self._mqtt_on_callback self._mqttc.on_unsubscribe = self._mqtt_on_callback - if ( - CONF_WILL_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_WILL_MESSAGE] - ): - will_message = PublishMessage(**self.conf[CONF_WILL_MESSAGE]) - else: - will_message = None - - if will_message is not None: + if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL): + will_message = PublishMessage(**will) self._mqttc.will_set( topic=will_message.topic, payload=will_message.payload, @@ -499,8 +481,8 @@ class MQTT: result = await self.hass.async_add_executor_job( self._mqttc.connect, self.conf[CONF_BROKER], - self.conf[CONF_PORT], - self.conf[CONF_KEEPALIVE], + self.conf.get(CONF_PORT, DEFAULT_PORT), + self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), ) except OSError as err: _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) @@ -734,16 +716,13 @@ class MQTT: _LOGGER.info( "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], - self.conf[CONF_PORT], + self.conf.get(CONF_PORT, DEFAULT_PORT), result_code, ) self.hass.create_task(self._async_resubscribe()) - if ( - CONF_BIRTH_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_BIRTH_MESSAGE] - ): + if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH): async def publish_birth_message(birth_message: PublishMessage) -> None: await self._ha_started.wait() # Wait for Home Assistant to start @@ -757,10 +736,13 @@ class MQTT: retain=birth_message.retain, ) - birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) + birth_message = PublishMessage(**birth) asyncio.run_coroutine_threadsafe( publish_birth_message(birth_message), self.hass.loop ) + else: + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" @@ -876,7 +858,7 @@ class MQTT: _LOGGER.warning( "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], - self.conf[CONF_PORT], + self.conf.get(CONF_PORT, DEFAULT_PORT), result_code, ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 77c3856aac1..bea8a900a83 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -12,9 +12,9 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_CLIENT_ID, CONF_DISCOVERY, @@ -25,7 +25,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_dumps @@ -47,7 +47,6 @@ from homeassistant.helpers.selector import ( from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from .client import MqttClientSetup -from .config_integration import CONFIG_SCHEMA_ENTRY from .const import ( ATTR_PAYLOAD, ATTR_QOS, @@ -155,7 +154,7 @@ CERT_UPLOAD_SELECTOR = FileSelector( KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) -class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 @@ -165,7 +164,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, + config_entry: ConfigEntry, ) -> MQTTOptionsFlowHandler: """Get the options flow for this handler.""" return MQTTOptionsFlowHandler(config_entry) @@ -187,7 +186,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} if await async_get_broker_settings( - self.hass, + self, fields, None, user_input, @@ -256,10 +255,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(config_entries.OptionsFlow): +class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize MQTT options flow.""" self.config_entry = config_entry self.broker_config: dict[str, str | int] = {} @@ -277,7 +276,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} if await async_get_broker_settings( - self.hass, + self, fields, self.config_entry.data, user_input, @@ -369,7 +368,6 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): updated_config = {} updated_config.update(self.broker_config) updated_config.update(options_config) - CONFIG_SCHEMA_ENTRY(updated_config) self.hass.config_entries.async_update_entry( self.config_entry, data=updated_config, @@ -450,7 +448,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): async def async_get_broker_settings( - hass: HomeAssistant, + flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, @@ -463,6 +461,7 @@ async def async_get_broker_settings( or when the advanced_broker_options checkbox was selected. Returns True when settings are collected successfully. """ + hass = flow.hass advanced_broker_options: bool = False user_input_basic: dict[str, Any] = {} current_config: dict[str, Any] = ( @@ -641,9 +640,12 @@ async def async_get_broker_settings( description={"suggested_value": current_pass}, ) ] = PASSWORD_SELECTOR - # show advanced options checkbox if requested + # show advanced options checkbox if requested and + # advanced options are enabled # or when the defaults of advanced options are overridden if not advanced_broker_options: + if not flow.show_advanced_options: + return False fields[ vol.Optional( ADVANCED_OPTIONS, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 47f8a7cf492..469f52e1488 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,37 +45,11 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TRANSPORT, CONF_WILL_MESSAGE, - CONF_WS_HEADERS, - CONF_WS_PATH, - DEFAULT_BIRTH, - DEFAULT_DISCOVERY, - DEFAULT_KEEPALIVE, - DEFAULT_PORT, - DEFAULT_PREFIX, - DEFAULT_PROTOCOL, - DEFAULT_TRANSPORT, - DEFAULT_WILL, - SUPPORTED_PROTOCOLS, - TRANSPORT_TCP, - TRANSPORT_WEBSOCKETS, ) -from .util import valid_birth_will, valid_publish_topic DEFAULT_TLS_PROTOCOL = "auto" -DEFAULT_VALUES = { - CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, - CONF_DISCOVERY: DEFAULT_DISCOVERY, - CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, - CONF_PORT: DEFAULT_PORT, - CONF_PROTOCOL: DEFAULT_PROTOCOL, - CONF_TRANSPORT: DEFAULT_TRANSPORT, - CONF_WILL_MESSAGE: DEFAULT_WILL, - CONF_KEEPALIVE: DEFAULT_KEEPALIVE, -} - PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All( @@ -166,61 +140,6 @@ CLIENT_KEY_AUTH_MSG = ( "client_key and client_cert must both be present in the MQTT broker configuration" ) -CONFIG_SCHEMA_ENTRY = vol.Schema( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): str, - vol.Inclusive(CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG): str, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): str, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, - vol.Optional(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.All( - cv.string, vol.In([TRANSPORT_TCP, TRANSPORT_WEBSOCKETS]) - ), - vol.Optional(CONF_WS_PATH, default="/"): cv.string, - vol.Optional(CONF_WS_HEADERS, default={}): {cv.string: cv.string}, - } -) - -CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), - vol.Inclusive( - CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), - vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, - vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, - } -) - DEPRECATED_CONFIG_KEYS = [ CONF_BIRTH_MESSAGE, CONF_BROKER, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 41fd353359e..c91c54a79a4 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -35,6 +35,7 @@ CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" DATA_MQTT = "mqtt" +DATA_MQTT_AVAILABLE = "mqtt_client_available" DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" @@ -46,6 +47,7 @@ DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False +DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" PROTOCOL_31 = "3.1" diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a764b24b2e8..342e7d121f2 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -218,7 +218,8 @@ async def async_start( # noqa: C901 discovery_hash = (component, discovery_id) if discovery_hash in mqtt_data.discovery_already_discovered or payload: - async def discovery_done(_: Any) -> None: + @callback + def discovery_done(_: Any) -> None: pending = mqtt_data.discovery_pending_discovered[discovery_hash][ "pending" ] @@ -310,10 +311,7 @@ async def async_start( # noqa: C901 and result["reason"] in ("already_configured", "single_instance_allowed") ): - unsub = mqtt_data.integration_unsubscribe.pop(key, None) - if unsub is None: - return - unsub() + mqtt_data.integration_unsubscribe.pop(key)() for topic in topics: key = f"{integration}_{topic}" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 74290abb757..e8259c60809 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import fan from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -56,6 +57,7 @@ from .mixins import ( warn_for_legacy_schema, ) from .models import ( + MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, @@ -64,6 +66,10 @@ from .models import ( ) from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" +CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" +CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" +CONF_DIRECTION_COMMAND_TEMPLATE = "direction_command_template" CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" @@ -128,6 +134,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_DIRECTION_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_DIRECTION_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_OSCILLATION_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_STATE_TOPIC): valid_subscribe_topic, @@ -225,6 +235,7 @@ class MqttFan(MqttEntity, FanEntity): _feature_preset_mode: bool _topic: dict[str, Any] _optimistic: bool + _optimistic_direction: bool _optimistic_oscillation: bool _optimistic_percentage: bool _optimistic_preset_mode: bool @@ -260,6 +271,8 @@ class MqttFan(MqttEntity, FanEntity): for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, + CONF_DIRECTION_COMMAND_TOPIC, CONF_PERCENTAGE_STATE_TOPIC, CONF_PERCENTAGE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, @@ -292,6 +305,9 @@ class MqttFan(MqttEntity, FanEntity): optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._optimistic_direction = ( + optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None + ) self._optimistic_oscillation = ( optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None ) @@ -307,6 +323,10 @@ class MqttFan(MqttEntity, FanEntity): self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and FanEntityFeature.OSCILLATE ) + self._attr_supported_features |= ( + self._topic[CONF_DIRECTION_COMMAND_TOPIC] is not None + and FanEntityFeature.DIRECTION + ) if self._feature_percentage: self._attr_supported_features |= FanEntityFeature.SET_SPEED if self._feature_preset_mode: @@ -314,6 +334,7 @@ class MqttFan(MqttEntity, FanEntity): command_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_COMMAND_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_COMMAND_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_COMMAND_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_COMMAND_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_COMMAND_TEMPLATE), @@ -327,6 +348,7 @@ class MqttFan(MqttEntity, FanEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_VALUE_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), @@ -341,6 +363,17 @@ class MqttFan(MqttEntity, FanEntity): """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} + def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + """Add a topic to subscribe to.""" + if has_topic := self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + return has_topic + @callback @log_messages(self.hass, self.entity_id) def state_received(msg: ReceiveMessage) -> None: @@ -357,13 +390,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_is_on = None get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) @@ -408,14 +435,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_percentage = percentage get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_PERCENTAGE_STATE_TOPIC] is not None: - topics[CONF_PERCENTAGE_STATE_TOPIC] = { - "topic": self._topic[CONF_PERCENTAGE_STATE_TOPIC], - "msg_callback": percentage_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_percentage = None + add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) @@ -441,14 +461,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_preset_mode = preset_mode get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_PRESET_MODE_STATE_TOPIC] is not None: - topics[CONF_PRESET_MODE_STATE_TOPIC] = { - "topic": self._topic[CONF_PRESET_MODE_STATE_TOPIC], - "msg_callback": preset_mode_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_preset_mode = None + add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) @@ -464,15 +477,22 @@ class MqttFan(MqttEntity, FanEntity): self._attr_oscillating = False get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - topics[CONF_OSCILLATION_STATE_TOPIC] = { - "topic": self._topic[CONF_OSCILLATION_STATE_TOPIC], - "msg_callback": oscillation_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False + @callback + @log_messages(self.hass, self.entity_id) + def direction_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the direction.""" + direction = self._value_templates[ATTR_DIRECTION](msg.payload) + if not direction: + _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) + return + self._attr_current_direction = str(direction) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) + self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) @@ -602,3 +622,22 @@ class MqttFan(MqttEntity, FanEntity): if self._optimistic_oscillation: self._attr_oscillating = oscillating self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set direction. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[ATTR_DIRECTION](direction) + + await self.async_publish( + self._topic[CONF_DIRECTION_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + if self._optimistic_direction: + self._attr_current_direction = direction + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 93069791a79..2c6dae54f4c 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -125,7 +125,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional( CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER ): vol.In( - [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER] + [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER, None] ), vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 358a97ed30d..b3659a67e61 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -468,6 +468,10 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return device_value = float(payload) + if device_value == 0: + _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) + return + percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min(round(percent_bright * 255), 255) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index e0b20436fe6..c40dae659b7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -378,11 +378,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: - self._attr_brightness = int( - values["brightness"] # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ) + if brightness := values["brightness"]: + self._attr_brightness = int( + brightness # type: ignore[operator] + / float(self._config[CONF_BRIGHTNESS_SCALE]) + * 255 + ) + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except KeyError: pass except (TypeError, ValueError): diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index d0eaa31548d..c2b4de289fd 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -236,11 +236,20 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if CONF_BRIGHTNESS_TEMPLATE in self._config: try: - self._attr_brightness = int( + if brightness := int( self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ) + ): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: - _LOGGER.warning("Invalid brightness value received") + _LOGGER.warning( + "Invalid brightness value received from %s", msg.topic + ) if CONF_COLOR_TEMP_TEMPLATE in self._config: try: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index cecb4b88bcd..38826438091 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -562,7 +562,6 @@ class MqttAvailability(Entity): def available(self) -> bool: """Return if the device is available.""" mqtt_data = get_mqtt_data(self.hass) - assert mqtt_data.client is not None client = mqtt_data.client if not client.connected and not self.hass.is_stopping: return False @@ -833,8 +832,37 @@ class MqttDiscoveryUpdate(Entity): else: await self.async_remove(force_remove=True) - async def discovery_callback(payload: MQTTDiscoveryPayload) -> None: - """Handle discovery update.""" + async def _async_process_discovery_update( + payload: MQTTDiscoveryPayload, + discovery_update: Callable[ + [MQTTDiscoveryPayload], Coroutine[Any, Any, None] + ], + discovery_data: DiscoveryInfoType, + ) -> None: + """Process discovery update.""" + try: + await discovery_update(payload) + finally: + send_discovery_done(self.hass, discovery_data) + + async def _async_process_discovery_update_and_remove( + payload: MQTTDiscoveryPayload, discovery_data: DiscoveryInfoType + ) -> None: + """Process discovery update and remove entity.""" + self._cleanup_discovery_on_remove() + await _async_remove_state_and_registry_entry(self) + send_discovery_done(self.hass, discovery_data) + + @callback + def discovery_callback(payload: MQTTDiscoveryPayload) -> None: + """Handle discovery update. + + If the payload has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", discovery_hash, @@ -847,17 +875,20 @@ class MqttDiscoveryUpdate(Entity): if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) - self._cleanup_discovery_on_remove() - await _async_remove_state_and_registry_entry(self) - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update_and_remove( + payload, self._discovery_data + ) + ) elif self._discovery_update: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: # Non-empty, changed payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - try: - await self._discovery_update(payload) - finally: - send_discovery_done(self.hass, self._discovery_data) + self.hass.async_create_task( + _async_process_discovery_update( + payload, self._discovery_update, self._discovery_data + ) + ) else: # Non-empty, unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) @@ -1162,10 +1193,9 @@ def async_removed_from_device( if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) - if not (device_entry := device_registry.async_get(device_id)): - # The device is already removed, do cleanup when we get "remove" event - return False - if config_entry_id in device_entry.config_entries: + if ( + device_entry := device_registry.async_get(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 84735c55e08..eac333e2a7a 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -288,8 +288,8 @@ class EntityTopicState: class MqttData: """Keep the MQTT entry data.""" - client: MQTT | None = None - config: ConfigType | None = None + client: MQTT + config: ConfigType debug_info_entities: dict[str, EntityDebugInfo] = field(default_factory=dict) debug_info_triggers: dict[tuple[str, str], TriggerDebugInfo] = field( default_factory=dict diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 62bb9123a76..1ab14b2b4f8 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -97,7 +97,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_STEP, default=DEFAULT_STEP): vol.All( vol.Coerce(float), vol.Range(min=1e-3) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index aea357bea62..9de442926a0 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -107,7 +107,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 29beafdbe84..7ccc31bd335 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -124,11 +124,9 @@ def async_prepare_subscribe_topics( async def async_subscribe_topics( hass: HomeAssistant, - sub_state: dict[str, EntitySubscription] | None, + sub_state: dict[str, EntitySubscription], ) -> None: """(Re)Subscribe to a set of MQTT topics.""" - if sub_state is None: - return for sub in sub_state.values(): await sub.subscribe() diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 44deb0781cb..896ba21f802 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -2,13 +2,16 @@ from __future__ import annotations +import asyncio import os from pathlib import Path import tempfile from typing import Any +import async_timeout import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType @@ -22,6 +25,7 @@ from .const import ( CONF_CLIENT_CERT, CONF_CLIENT_KEY, DATA_MQTT, + DATA_MQTT_AVAILABLE, DEFAULT_ENCODING, DEFAULT_QOS, DEFAULT_RETAIN, @@ -29,6 +33,8 @@ from .const import ( ) from .models import MqttData +AVAILABILITY_TIMEOUT = 30.0 + TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" _VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2])) @@ -41,6 +47,37 @@ def mqtt_config_entry_enabled(hass: HomeAssistant) -> bool | None: return not bool(hass.config_entries.async_entries(DOMAIN)[0].disabled_by) +async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: + """Wait for the MQTT client to become available. + + Waits when mqtt set up is in progress, + It is not needed that the client is connected. + Returns True if the mqtt client is available. + Returns False when the client is not available. + """ + if not mqtt_config_entry_enabled(hass): + return False + + entry = hass.config_entries.async_entries(DOMAIN)[0] + if entry.state == ConfigEntryState.LOADED: + return True + + state_reached_future: asyncio.Future[bool] + if DATA_MQTT_AVAILABLE not in hass.data: + hass.data[DATA_MQTT_AVAILABLE] = state_reached_future = asyncio.Future() + else: + state_reached_future = hass.data[DATA_MQTT_AVAILABLE] + if state_reached_future.done(): + return state_reached_future.result() + + try: + async with async_timeout.timeout(AVAILABILITY_TIMEOUT): + # Await the client setup or an error state was received + return await state_reached_future + except asyncio.TimeoutError: + return False + + def valid_topic(topic: Any) -> str: """Validate that this is a valid topic name/filter.""" validated_topic = cv.string(topic) @@ -136,12 +173,9 @@ def valid_birth_will(config: ConfigType) -> ConfigType: return config -def get_mqtt_data(hass: HomeAssistant, ensure_exists: bool = False) -> MqttData: +def get_mqtt_data(hass: HomeAssistant) -> MqttData: """Return typed MqttData from hass.data[DATA_MQTT].""" mqtt_data: MqttData - if ensure_exists: - mqtt_data = hass.data.setdefault(DATA_MQTT, MqttData()) - return mqtt_data mqtt_data = hass.data[DATA_MQTT] return mqtt_data diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 2c67751551c..2b355eb68e6 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -47,6 +47,13 @@ async def async_setup_scanner( discovery_info: DiscoveryInfoType | None = None, ) -> bool: """Set up the MQTT JSON tracker.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the device_tracker platform setup + # also will be triggered when mqtt is loading the `device_tracker` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + devices = config[CONF_DEVICES] qos = config[CONF_QOS] diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index b1b52e42fce..00441690b47 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -68,6 +68,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up MQTT room Sensor.""" + # Make sure MQTT integration is enabled and the client is available + # We cannot count on dependencies as the sensor platform setup + # also will be triggered when mqtt is loading the `sensor` platform + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return async_add_entities( [ MQTTRoomSensor( diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 68f8bb566f1..213e268696e 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -172,7 +172,7 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 682ca7756e0..e60855b882c 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -89,13 +89,13 @@ } }, "pmsx003_pm1": { - "name": "PMSx003 particulate matter 1 μm" + "name": "PMSx003 PM1" }, "pmsx003_pm10": { - "name": "PMSx003 particulate matter 10 μm" + "name": "PMSx003 PM10" }, "pmsx003_pm25": { - "name": "PMSx003 particulate matter 2.5 μm" + "name": "PMSx003 PM2.5" }, "sds011_caqi": { "name": "SDS011 common air quality index" @@ -111,10 +111,10 @@ } }, "sds011_pm10": { - "name": "SDS011 particulate matter 10 μm" + "name": "SDS011 PM10" }, "sds011_pm25": { - "name": "SDS011 particulate matter 2.5 μm" + "name": "SDS011 PM2.5" }, "sht3x_humidity": { "name": "SHT3X humidity" @@ -136,16 +136,16 @@ } }, "sps30_pm1": { - "name": "SPS30 particulate matter 1 μm" + "name": "SPS30 PM1" }, "sps30_pm10": { - "name": "SPS30 particulate matter 10 μm" + "name": "SPS30 PM10" }, "sps30_pm25": { - "name": "SPS30 particulate matter 2.5 μm" + "name": "SPS30 PM2.5" }, "sps30_pm4": { - "name": "SPS30 Particulate matter 4 μm" + "name": "SPS30 PM4" }, "dht22_humidity": { "name": "DHT22 humidity" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7f..f0c782bc1b5 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ class NSDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index c0c7042423b..4176ad1e227 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -61,7 +61,7 @@ }, "error": { "timeout": "Timeout validating code", - "invalid_pin": "Invalid [%key:common::config_flow::data::pin%]", + "invalid_pin": "Invalid PIN", "unknown": "[%key:common::config_flow::error::unknown%]", "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 80396e8048c..c80bd351ccf 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -134,8 +134,10 @@ class NetatmoDataHandler: async def async_setup(self) -> None: """Set up the Netatmo data handler.""" - async_track_time_interval( - self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + self.config_entry.async_on_unload( + async_track_time_interval( + self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + ) ) self.config_entry.async_on_unload( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index f58daadcf7f..a500689a937 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -25,10 +25,10 @@ "public_weather": { "data": { "area_name": "Name of the area", - "lat_ne": "[%key:common::config_flow::data::latitude%] North-East corner", - "lon_ne": "[%key:common::config_flow::data::longitude%] North-East corner", - "lat_sw": "[%key:common::config_flow::data::latitude%] South-West corner", - "lon_sw": "[%key:common::config_flow::data::longitude%] South-West corner", + "lat_ne": "North-East corner latitude", + "lon_ne": "North-East corner longitude", + "lat_sw": "South-West corner latitude", + "lon_sw": "South-West corner longitude", "mode": "Calculation", "show_on_map": "Show on map" }, diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index d58c4878f65..7941d1fe0a7 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,8 +4,8 @@ "user": { "description": "Default host: {host}\nDefault username: {username}", "data": { - "host": "[%key:common::config_flow::data::host%] (Optional)", - "username": "[%key:common::config_flow::data::username%] (Optional)", + "host": "Host (Optional)", + "username": "Username (Optional)", "password": "[%key:common::config_flow::data::password%]" } } diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d7225..a9023ffca2b 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ DAILY_NAME = "Daily Energy Usage" ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ class NeurioData: class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ class NeurioEnergy(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 782865032af..e068ae4041e 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -31,8 +31,8 @@ }, "issues": { "deprecated_yaml": { - "title": "The Netxcloud YAML configuration has been deprecated", - "description": "Configuring Netxcloud 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." + "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/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 2a1240322b7..2f13632dc46 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==1.3.0"] + "requirements": ["nextdns==1.4.0"] } diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 517e229d0e4..2f15c4cd8e5 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -133,12 +133,18 @@ "block_amazon": { "name": "Block Amazon" }, + "block_bereal": { + "name": "Block BeReal" + }, "block_blizzard": { "name": "Block Blizzard" }, "block_bypass_methods": { "name": "Block bypass methods" }, + "block_chatgpt": { + "name": "Block ChatGPT" + }, "block_csam": { "name": "Block child sexual abuse material" }, @@ -172,6 +178,12 @@ "block_gambling": { "name": "Block gambling" }, + "block_google_chat": { + "name": "Block Google Chat" + }, + "block_hbomax": { + "name": "Block HBO Max" + }, "block_hulu": { "name": "Block Hulu" }, @@ -184,6 +196,9 @@ "block_leagueoflegends": { "name": "Block League of Legends" }, + "block_mastodon": { + "name": "Block Mastodon" + }, "block_messenger": { "name": "Block Messenger" }, @@ -196,6 +211,9 @@ "block_nrd": { "name": "Block newly registered domains" }, + "block_online_gaming": { + "name": "Block online gaming" + }, "block_page": { "name": "Block page" }, @@ -208,6 +226,9 @@ "block_piracy": { "name": "Block piracy" }, + "block_playstation_network": { + "name": "Block PlayStation Network" + }, "block_porn": { "name": "Block porn" }, @@ -256,6 +277,9 @@ "block_twitter": { "name": "Block Twitter" }, + "block_video_streaming": { + "name": "Block video streaming" + }, "block_vimeo": { "name": "Block Vimeo" }, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index cd584b27713..0a310bc29e7 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -204,6 +204,14 @@ SWITCHES = ( icon="mdi:cart-outline", state=lambda data: data.block_amazon, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_bereal", + translation_key="block_bereal", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:alpha-b-box", + state=lambda data: data.block_bereal, + ), NextDnsSwitchEntityDescription[Settings]( key="block_blizzard", translation_key="block_blizzard", @@ -212,6 +220,14 @@ SWITCHES = ( icon="mdi:sword-cross", state=lambda data: data.block_blizzard, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_chatgpt", + translation_key="block_chatgpt", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:chat-processing-outline", + state=lambda data: data.block_chatgpt, + ), NextDnsSwitchEntityDescription[Settings]( key="block_dailymotion", translation_key="block_dailymotion", @@ -260,6 +276,22 @@ SWITCHES = ( icon="mdi:tank", state=lambda data: data.block_fortnite, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_google_chat", + translation_key="block_google_chat", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:forum", + state=lambda data: data.block_google_chat, + ), + NextDnsSwitchEntityDescription[Settings]( + key="block_hbomax", + translation_key="block_hbomax", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:movie-search-outline", + state=lambda data: data.block_hbomax, + ), NextDnsSwitchEntityDescription[Settings]( key="block_hulu", name="Block Hulu", @@ -292,6 +324,14 @@ SWITCHES = ( icon="mdi:sword", state=lambda data: data.block_leagueoflegends, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_mastodon", + translation_key="block_mastodon", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:mastodon", + state=lambda data: data.block_mastodon, + ), NextDnsSwitchEntityDescription[Settings]( key="block_messenger", translation_key="block_messenger", @@ -324,6 +364,14 @@ SWITCHES = ( icon="mdi:pinterest", state=lambda data: data.block_pinterest, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_playstation_network", + translation_key="block_playstation_network", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:sony-playstation", + state=lambda data: data.block_playstation_network, + ), NextDnsSwitchEntityDescription[Settings]( key="block_primevideo", translation_key="block_primevideo", @@ -500,6 +548,14 @@ SWITCHES = ( icon="mdi:slot-machine", state=lambda data: data.block_gambling, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_online_gaming", + translation_key="block_online_gaming", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:gamepad-variant", + state=lambda data: data.block_online_gaming, + ), NextDnsSwitchEntityDescription[Settings]( key="block_piracy", translation_key="block_piracy", @@ -524,6 +580,14 @@ SWITCHES = ( icon="mdi:facebook", state=lambda data: data.block_social_networks, ), + NextDnsSwitchEntityDescription[Settings]( + key="block_video_streaming", + translation_key="block_video_streaming", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:video-wireless-outline", + state=lambda data: data.block_video_streaming, + ), ) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 4d12591a472..b541a145a66 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -8,8 +8,13 @@ from typing import Any import nikohomecontrol import voluptuous as vol -# Import the device class from the component that you want to support -from homeassistant.components.light import PLATFORM_SCHEMA, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -52,36 +57,23 @@ async def async_setup_platform( class NikoHomeControlLight(LightEntity): """Representation of an Niko Light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, light, data): """Set up the Niko Home Control light platform.""" self._data = data self._light = light - self._unique_id = f"light-{light.id}" - self._name = light.name - self._state = light.is_on - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_unique_id = f"light-{light.id}" + self._attr_name = light.name + self._attr_is_on = light.is_on + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + if light._state["type"] == 2: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on() + self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -91,7 +83,10 @@ class NikoHomeControlLight(LightEntity): async def async_update(self) -> None: """Get the latest data from NikoHomeControl API.""" await self._data.async_update() - self._state = self._data.get_state(self._light.id) + state = self._data.get_state(self._light.id) + self._attr_is_on = state != 0 + if brightness_supported(self.supported_color_modes): + self._attr_brightness = state * 2.55 class NikoHomeControlData: @@ -122,5 +117,5 @@ class NikoHomeControlData: """Find and filter state based on action id.""" for state in self.data: if state["id"] == aid: - return state["value1"] != 0 + return state["value1"] _LOGGER.error("Failed to retrieve state off unknown light") diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c59460..3745c6bae6f 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ class NiluData: class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ class NiluSensor(AirQualityEntity): self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index be14b57ed47..6386a70d08b 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["pynina==0.2.0"] + "requirements": ["pynina==0.3.0"] } diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc9..1a3d3661a15 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def round_state(func): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ class AirSensor(AirQualityEntity): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index eaa3f55e56c..5e55496fc54 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass, field, fields from datetime import timedelta import logging import traceback from typing import Any +from uuid import UUID from aionotion import async_get_client +from aionotion.bridge.models import Bridge from aionotion.errors import InvalidCredentialsError, NotionError +from aionotion.sensor.models import Listener, ListenerKind, Sensor from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -18,6 +22,7 @@ from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, + entity_registry as er, ) from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( @@ -26,7 +31,20 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SENSOR_BATTERY, + SENSOR_DOOR, + SENSOR_GARAGE_DOOR, + SENSOR_LEAK, + SENSOR_MISSING, + SENSOR_SAFE, + SENSOR_SLIDING, + SENSOR_SMOKE_CO, + SENSOR_TEMPERATURE, + SENSOR_WINDOW_HINGED, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -37,6 +55,51 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +# Define a map of old-API task types to new-API listener types: +TASK_TYPE_TO_LISTENER_MAP: dict[str, ListenerKind] = { + SENSOR_BATTERY: ListenerKind.BATTERY, + SENSOR_DOOR: ListenerKind.DOOR, + SENSOR_GARAGE_DOOR: ListenerKind.GARAGE_DOOR, + SENSOR_LEAK: ListenerKind.LEAK_STATUS, + SENSOR_MISSING: ListenerKind.CONNECTED, + SENSOR_SAFE: ListenerKind.SAFE, + SENSOR_SLIDING: ListenerKind.SLIDING_DOOR_OR_WINDOW, + SENSOR_SMOKE_CO: ListenerKind.SMOKE, + SENSOR_TEMPERATURE: ListenerKind.TEMPERATURE, + SENSOR_WINDOW_HINGED: ListenerKind.HINGED_WINDOW, +} + + +@callback +def is_uuid(value: str) -> bool: + """Return whether a string is a valid UUID.""" + try: + UUID(value) + except ValueError: + return False + return True + + +@dataclass +class NotionData: + """Define a manager class for Notion data.""" + + # Define a dict of bridges, indexed by bridge ID (an integer): + bridges: dict[int, Bridge] = field(default_factory=dict) + + # Define a dict of listeners, indexed by listener UUID (a string): + listeners: dict[str, Listener] = field(default_factory=dict) + + # Define a dict of sensors, indexed by sensor UUID (a string): + sensors: dict[str, Sensor] = field(default_factory=dict) + + def asdict(self) -> dict[str, Any]: + """Represent this dataclass (and its Pydantic contents) as a dict.""" + return { + field.name: [obj.dict() for obj in getattr(self, field.name).values()] + for field in fields(self) + } + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" @@ -56,13 +119,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except NotionError as err: raise ConfigEntryNotReady("Config entry failed to load") from err - async def async_update() -> dict[str, dict[str, Any]]: + async def async_update() -> NotionData: """Get the latest data from the Notion API.""" - data: dict[str, dict[str, Any]] = {"bridges": {}, "sensors": {}, "tasks": {}} + data = NotionData() tasks = { "bridges": client.bridge.async_all(), + "listeners": client.sensor.async_listeners(), "sensors": client.sensor.async_all(), - "tasks": client.task.async_all(), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) @@ -83,10 +146,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from result for item in result: - if attr == "bridges" and item["id"] not in data["bridges"]: + if attr == "bridges": # If a new bridge is discovered, register it: - _async_register_new_bridge(hass, item, entry) - data[attr][item["id"]] = item + if item.id not in data.bridges: + _async_register_new_bridge(hass, item, entry) + data.bridges[item.id] = item + elif attr == "listeners": + data.listeners[item.id] = item + else: + data.sensors[item.uuid] = item return data @@ -102,6 +170,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + @callback + def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate Notion entity entries. + + This migration focuses on unique IDs, which have changed because of a Notion API + change: + + Old Format: _ + New Format: + """ + if is_uuid(entry.unique_id): + # If the unique ID is already a UUID, we don't need to migrate it: + return None + + sensor_id_str, task_type = entry.unique_id.split("_", 1) + sensor = next( + sensor + for sensor in coordinator.data.sensors.values() + if sensor.id == int(sensor_id_str) + ) + listener = next( + listener + for listener in coordinator.data.listeners.values() + if listener.sensor_id == sensor.uuid + and listener.listener_kind == TASK_TYPE_TO_LISTENER_MAP[task_type] + ) + + return {"new_unique_id": listener.id} + + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -118,61 +216,59 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_register_new_bridge( - hass: HomeAssistant, bridge: dict, entry: ConfigEntry + hass: HomeAssistant, bridge: Bridge, entry: ConfigEntry ) -> None: """Register a new bridge.""" - if name := bridge["name"]: + if name := bridge.name: bridge_name = name.capitalize() else: - bridge_name = bridge["id"] + bridge_name = str(bridge.id) device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, bridge["hardware_id"])}, + identifiers={(DOMAIN, bridge.hardware_id)}, manufacturer="Silicon Labs", - model=bridge["hardware_revision"], + model=str(bridge.hardware_revision), name=bridge_name, - sw_version=bridge["firmware_version"]["wifi"], + sw_version=bridge.firmware_version.wifi, ) -class NotionEntity(CoordinatorEntity): +class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): """Define a base Notion entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator, - task_id: str, + coordinator: DataUpdateCoordinator[NotionData], + listener_id: str, sensor_id: str, - bridge_id: str, + bridge_id: int, system_id: str, description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - bridge = self.coordinator.data["bridges"].get(bridge_id, {}) - sensor = self.coordinator.data["sensors"][sensor_id] + bridge = self.coordinator.data.bridges[bridge_id] + sensor = self.coordinator.data.sensors[sensor_id] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sensor["hardware_id"])}, + identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", - model=sensor["hardware_revision"], - name=str(sensor["name"]).capitalize(), - sw_version=sensor["firmware_version"], - via_device=(DOMAIN, bridge.get("hardware_id")), + model=str(sensor.hardware_revision), + name=str(sensor.name).capitalize(), + sw_version=sensor.firmware_version, + via_device=(DOMAIN, bridge.hardware_id), ) self._attr_extra_state_attributes = {} - self._attr_unique_id = ( - f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' - ) + self._attr_unique_id = listener_id self._bridge_id = bridge_id + self._listener_id = listener_id self._sensor_id = sensor_id self._system_id = system_id - self._task_id = task_id self.entity_description = description @property @@ -180,7 +276,7 @@ class NotionEntity(CoordinatorEntity): """Return True if entity is available.""" return ( self.coordinator.last_update_success - and self._task_id in self.coordinator.data["tasks"] + and self._listener_id in self.coordinator.data.listeners ) @callback @@ -189,27 +285,23 @@ class NotionEntity(CoordinatorEntity): Sensors can move to other bridges based on signal strength, etc. """ - sensor = self.coordinator.data["sensors"][self._sensor_id] + sensor = self.coordinator.data.sensors[self._sensor_id] # If the sensor's bridge ID is the same as what we had before or if it points # to a bridge that doesn't exist (which can happen due to a Notion API bug), # return immediately: if ( - self._bridge_id == sensor["bridge"]["id"] - or sensor["bridge"]["id"] not in self.coordinator.data["bridges"] + self._bridge_id == sensor.bridge.id + or sensor.bridge.id not in self.coordinator.data.bridges ): return - self._bridge_id = sensor["bridge"]["id"] + self._bridge_id = sensor.bridge.id device_registry = dr.async_get(self.hass) - this_device = device_registry.async_get_device( - {(DOMAIN, sensor["hardware_id"])} - ) - bridge = self.coordinator.data["bridges"][self._bridge_id] - bridge_device = device_registry.async_get_device( - {(DOMAIN, bridge["hardware_id"])} - ) + this_device = device_registry.async_get_device({(DOMAIN, sensor.hardware_id)}) + bridge = self.coordinator.data.bridges[self._bridge_id] + bridge_device = device_registry.async_get_device({(DOMAIN, bridge.hardware_id)}) if not bridge_device or not this_device: return @@ -226,7 +318,7 @@ class NotionEntity(CoordinatorEntity): @callback def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" - if self._task_id in self.coordinator.data["tasks"]: + if self._listener_id in self.coordinator.data.listeners: self._async_update_bridge_id() self._async_update_from_latest_data() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index f5d40b2a9de..bd2de303d2d 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal +from aionotion.sensor.models import ListenerKind + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -26,9 +28,9 @@ from .const import ( SENSOR_SAFE, SENSOR_SLIDING, SENSOR_SMOKE_CO, - SENSOR_WINDOW_HINGED_HORIZONTAL, - SENSOR_WINDOW_HINGED_VERTICAL, + SENSOR_WINDOW_HINGED, ) +from .model import NotionEntityDescriptionMixin @dataclass @@ -40,7 +42,9 @@ class NotionBinarySensorDescriptionMixin: @dataclass class NotionBinarySensorDescription( - BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin + BinarySensorEntityDescription, + NotionBinarySensorDescriptionMixin, + NotionEntityDescriptionMixin, ): """Describe a Notion binary sensor.""" @@ -51,24 +55,28 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Low battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, + listener_kind=ListenerKind.BATTERY, on_state="critical", ), NotionBinarySensorDescription( key=SENSOR_DOOR, name="Door", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, name="Garage door", device_class=BinarySensorDeviceClass.GARAGE_DOOR, + listener_kind=ListenerKind.GARAGE_DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_LEAK, name="Leak detector", device_class=BinarySensorDeviceClass.MOISTURE, + listener_kind=ListenerKind.LEAK_STATUS, on_state="leak", ), NotionBinarySensorDescription( @@ -76,36 +84,34 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Missing", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + listener_kind=ListenerKind.CONNECTED, on_state="not_missing", ), NotionBinarySensorDescription( key=SENSOR_SAFE, name="Safe", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.SAFE, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SLIDING, name="Sliding door/window", device_class=BinarySensorDeviceClass.DOOR, + listener_kind=ListenerKind.SLIDING_DOOR_OR_WINDOW, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, name="Smoke/Carbon monoxide detector", device_class=BinarySensorDeviceClass.SMOKE, + listener_kind=ListenerKind.SMOKE, on_state="alarm", ), NotionBinarySensorDescription( - key=SENSOR_WINDOW_HINGED_HORIZONTAL, + key=SENSOR_WINDOW_HINGED, name="Hinged window", - device_class=BinarySensorDeviceClass.WINDOW, - on_state="open", - ), - NotionBinarySensorDescription( - key=SENSOR_WINDOW_HINGED_VERTICAL, - name="Hinged window", - device_class=BinarySensorDeviceClass.WINDOW, + listener_kind=ListenerKind.HINGED_WINDOW, on_state="open", ), ) @@ -121,16 +127,16 @@ async def async_setup_entry( [ NotionBinarySensor( coordinator, - task_id, - sensor["id"], - sensor["bridge"]["id"], - sensor["system_id"], + listener_id, + sensor.uuid, + sensor.bridge.id, + sensor.system_id, description, ) - for task_id, task in coordinator.data["tasks"].items() + for listener_id, listener in coordinator.data.listeners.items() for description in BINARY_SENSOR_DESCRIPTIONS - if description.key == task["task_type"] - and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + if description.listener_kind == listener.listener_kind + and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -143,14 +149,14 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] + listener = self.coordinator.data.listeners[self._listener_id] - if "value" in task["status"]: - state = task["status"]["value"] - elif task["status"].get("insights", {}).get("primary"): - state = task["status"]["insights"]["primary"]["to_state"] + if listener.status.trigger_value: + state = listener.status.trigger_value + elif listener.insights.primary.value: + state = listener.insights.primary.value else: - LOGGER.warning("Unknown data payload: %s", task["status"]) + LOGGER.warning("Unknown listener structure: %s", listener) state = None self._attr_is_on = self.entity_description.on_state == state diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 339d3020734..5e89767d0e0 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -13,5 +13,4 @@ SENSOR_SAFE = "safe" SENSOR_SLIDING = "sliding" SENSOR_SMOKE_CO = "alarm" SENSOR_TEMPERATURE = "temperature" -SENSOR_WINDOW_HINGED_HORIZONTAL = "window_hinged_horizontal" -SENSOR_WINDOW_HINGED_VERTICAL = "window_hinged_vertical" +SENSOR_WINDOW_HINGED = "window_hinged" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 9b0a070897c..06100580b39 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from . import NotionData from .const import DOMAIN CONF_DEVICE_KEY = "device_key" @@ -33,9 +34,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: DataUpdateCoordinator[NotionData] = hass.data[DOMAIN][entry.entry_id] - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "data": async_redact_data(coordinator.data, TO_REDACT), - } + return async_redact_data( + { + "entry": entry.as_dict(), + "data": coordinator.data.asdict(), + }, + TO_REDACT, + ) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index a2a01ca113b..7eb2ef6bba3 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==3.0.2"] + "requirements": ["aionotion==2023.04.2"] } diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py new file mode 100644 index 00000000000..0999df3abdb --- /dev/null +++ b/homeassistant/components/notion/model.py @@ -0,0 +1,11 @@ +"""Define Notion model mixins.""" +from dataclasses import dataclass + +from aionotion.sensor.models import ListenerKind + + +@dataclass +class NotionEntityDescriptionMixin: + """Define an description mixin Notion entities.""" + + listener_kind: ListenerKind diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 7881780c4ed..f4e6e7cc322 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,4 +1,8 @@ """Support for Notion sensors.""" +from dataclasses import dataclass + +from aionotion.sensor.models import ListenerKind + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,14 +16,22 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE +from .model import NotionEntityDescriptionMixin + + +@dataclass +class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): + """Describe a Notion sensor.""" + SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + NotionSensorDescription( key=SENSOR_TEMPERATURE, name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + listener_kind=ListenerKind.TEMPERATURE, ), ) @@ -34,16 +46,16 @@ async def async_setup_entry( [ NotionSensor( coordinator, - task_id, - sensor["id"], - sensor["bridge"]["id"], - sensor["system_id"], + listener_id, + sensor.uuid, + sensor.bridge.id, + sensor.system_id, description, ) - for task_id, task in coordinator.data["tasks"].items() + for listener_id, listener in coordinator.data.listeners.items() for description in SENSOR_DESCRIPTIONS - if description.key == task["task_type"] - and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + if description.listener_kind == listener.listener_kind + and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -54,13 +66,12 @@ class NotionSensor(NotionEntity, SensorEntity): @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self._task_id] + listener = self.coordinator.data.listeners[self._listener_id] - if task["task_type"] == SENSOR_TEMPERATURE: - self._attr_native_value = round(float(task["status"]["value"]), 1) + if listener.listener_kind == ListenerKind.TEMPERATURE: + self._attr_native_value = round(listener.status.temperature, 1) # type: ignore[attr-defined] else: LOGGER.error( - "Unknown task type: %s: %s", - self.coordinator.data["sensors"][self._sensor_id], - task["task_type"], + "Unknown listener type for sensor %s", + self.coordinator.data.sensors[self._sensor_id], ) diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e07611..44adb78e6a0 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ from . import ( _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ class NumatoGpioAdc(SensorEntity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index b4110736e55..6bf5b68e927 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from typing import cast import async_timeout from pynut2.nut2 import PyNUTClient, PyNUTError @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,9 +28,11 @@ from .const import ( COORDINATOR, DEFAULT_SCAN_INTERVAL, DOMAIN, + INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS, PYNUT_DATA, PYNUT_UNIQUE_ID, + USER_AVAILABLE_COMMANDS, ) NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -86,11 +90,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unique_id is None: unique_id = entry.entry_id + if username is not None and password is not None: + user_available_commands = { + device_supported_command + for device_supported_command in data.list_commands() or {} + if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + else: + user_available_commands = set() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { COORDINATOR: coordinator, PYNUT_DATA: data, PYNUT_UNIQUE_ID: unique_id, + USER_AVAILABLE_COMMANDS: user_available_commands, } device_registry = dr.async_get(hass) @@ -270,3 +284,24 @@ class PyNUTData: self._status = self._get_status() if self._device_info is None: self._device_info = self._get_device_info() + + async def async_run_command( + self, hass: HomeAssistant, command_name: str | None + ) -> None: + """Invoke instant command in UPS.""" + try: + await hass.async_add_executor_job( + self._client.run_command, self._alias, command_name + ) + except PyNUTError as err: + raise HomeAssistantError( + f"Error running command {command_name}, {err}" + ) from err + + def list_commands(self) -> dict[str, str] | None: + """Fetch the list of supported commands.""" + try: + return cast(dict[str, str], self._client.list_commands(self._alias)) + except PyNUTError as err: + _LOGGER.error("Error retrieving supported commands %s", err) + return None diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a96b39e6d78..3041ac38726 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -21,6 +21,8 @@ PYNUT_DATA = "data" PYNUT_UNIQUE_ID = "unique_id" +USER_AVAILABLE_COMMANDS = "user_available_commands" + STATE_TYPES = { "OL": "Online", "OB": "On Battery", @@ -38,3 +40,59 @@ STATE_TYPES = { "FSD": "Forced Shutdown", "ALARM": "Alarm", } + +COMMAND_BEEPER_DISABLE = "beeper.disable" +COMMAND_BEEPER_ENABLE = "beeper.enable" +COMMAND_BEEPER_MUTE = "beeper.mute" +COMMAND_BEEPER_TOGGLE = "beeper.toggle" +COMMAND_BYPASS_START = "bypass.start" +COMMAND_BYPASS_STOP = "bypass.stop" +COMMAND_CALIBRATE_START = "calibrate.start" +COMMAND_CALIBRATE_STOP = "calibrate.stop" +COMMAND_LOAD_OFF = "load.off" +COMMAND_LOAD_ON = "load.on" +COMMAND_RESET_INPUT_MINMAX = "reset.input.minmax" +COMMAND_RESET_WATCHDOG = "reset.watchdog" +COMMAND_SHUTDOWN_REBOOT = "shutdown.reboot" +COMMAND_SHUTDOWN_REBOOT_GRACEFUL = "shutdown.reboot.graceful" +COMMAND_SHUTDOWN_RETURN = "shutdown.return" +COMMAND_SHUTDOWN_STAYOFF = "shutdown.stayoff" +COMMAND_SHUTDOWN_STOP = "shutdown.stop" +COMMAND_TEST_BATTERY_START = "test.battery.start" +COMMAND_TEST_BATTERY_START_DEEP = "test.battery.start.deep" +COMMAND_TEST_BATTERY_START_QUICK = "test.battery.start.quick" +COMMAND_TEST_BATTERY_STOP = "test.battery.stop" +COMMAND_TEST_FAILURE_START = "test.failure.start" +COMMAND_TEST_FAILURE_STOP = "test.failure.stop" +COMMAND_TEST_PANEL_START = "test.panel.start" +COMMAND_TEST_PANEL_STOP = "test.panel.stop" +COMMAND_TEST_SYSTEM_START = "test.system.start" + +INTEGRATION_SUPPORTED_COMMANDS = { + COMMAND_BEEPER_DISABLE, + COMMAND_BEEPER_ENABLE, + COMMAND_BEEPER_MUTE, + COMMAND_BEEPER_TOGGLE, + COMMAND_BYPASS_START, + COMMAND_BYPASS_STOP, + COMMAND_CALIBRATE_START, + COMMAND_CALIBRATE_STOP, + COMMAND_LOAD_OFF, + COMMAND_LOAD_ON, + COMMAND_RESET_INPUT_MINMAX, + COMMAND_RESET_WATCHDOG, + COMMAND_SHUTDOWN_REBOOT, + COMMAND_SHUTDOWN_REBOOT_GRACEFUL, + COMMAND_SHUTDOWN_RETURN, + COMMAND_SHUTDOWN_STAYOFF, + COMMAND_SHUTDOWN_STOP, + COMMAND_TEST_BATTERY_START, + COMMAND_TEST_BATTERY_START_DEEP, + COMMAND_TEST_BATTERY_START_QUICK, + COMMAND_TEST_BATTERY_STOP, + COMMAND_TEST_FAILURE_START, + COMMAND_TEST_FAILURE_STOP, + COMMAND_TEST_PANEL_START, + COMMAND_TEST_PANEL_STOP, + COMMAND_TEST_SYSTEM_START, +} diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py new file mode 100644 index 00000000000..4898d9cc82d --- /dev/null +++ b/homeassistant/components/nut/device_action.py @@ -0,0 +1,75 @@ +"""Provides device actions for Network UPS Tools (NUT).""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import PyNUTData +from .const import ( + DOMAIN, + INTEGRATION_SUPPORTED_COMMANDS, + PYNUT_DATA, + USER_AVAILABLE_COMMANDS, +) + +ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + } +) + + +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device actions for Network UPS Tools (NUT) devices.""" + if (entry_id := _get_entry_id_from_device_id(hass, device_id)) is None: + return [] + base_action = { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + user_available_commands: set[str] = hass.data[DOMAIN][entry_id][ + USER_AVAILABLE_COMMANDS + ] + return [ + {CONF_TYPE: _get_device_action_name(command_name)} | base_action + for command_name in user_available_commands + ] + + +async def async_call_action_from_config( + hass: HomeAssistant, + config: ConfigType, + variables: TemplateVarsType, + context: Context | None, +) -> None: + """Execute a device action.""" + device_action_name: str = config[CONF_TYPE] + command_name = _get_command_name(device_action_name) + device_id: str = config[CONF_DEVICE_ID] + entry_id = _get_entry_id_from_device_id(hass, device_id) + data: PyNUTData = hass.data[DOMAIN][entry_id][PYNUT_DATA] + await data.async_run_command(hass, command_name) + + +def _get_device_action_name(command_name: str) -> str: + return command_name.replace(".", "_") + + +def _get_command_name(device_action_name: str) -> str: + return device_action_name.replace("_", ".") + + +def _get_entry_id_from_device_id(hass: HomeAssistant, device_id: str) -> str | None: + device_registry = dr.async_get(hass) + if (device := device_registry.async_get(device_id)) is None: + return None + return next(entry for entry in device.config_entries) diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 9085b28c5cf..0303dd70ec1 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,7 +1,7 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 9ac05546b32..a07e0ec2f7c 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -34,6 +34,36 @@ } } }, + "device_automation": { + "action_type": { + "beeper_disable": "Disable UPS beeper/buzzer", + "beeper_enable": "Enable UPS beeper/buzzer", + "beeper_mute": "Temporarily mute UPS beeper/buzzer", + "beeper_toggle": "Toggle UPS beeper/buzzer", + "bypass_start": "Put the UPS in bypass mode", + "bypass_stop": "Take the UPS out of bypass mode", + "calibrate_start": "Start runtime calibration", + "calibrate_stop": "Stop runtime calibration", + "load_off": "Turn off the load immediately", + "load_on": "Turn on the load immediately", + "reset_input_minmax": "Reset minimum and maximum input voltage status", + "reset_watchdog": "Reset watchdog timer (forced reboot of load)", + "shutdown_reboot": "Shut down the load briefly while rebooting the UPS", + "shutdown_reboot_graceful": "After a delay, shut down the load briefly while rebooting the UPS", + "shutdown_return": "Turn off the load possibly after a delay and return when power is back", + "shutdown_stayoff": "Turn off the load possibly after a delay and remain off even if power returns", + "shutdown_stop": "Stop a shutdown in progress", + "test_battery_start": "Start a battery test", + "test_battery_start_deep": "Start a deep battery test", + "test_battery_start_quick": "Start a quick battery test", + "test_battery_stop": "Stop the battery test", + "test_failure_start": "Start a simulated power failure", + "test_failure_stop": "Stop simulating a power failure", + "test_panel_start": "Start testing the UPS panel", + "test_panel_stop": "Stop a UPS panel test", + "test_system_start": "Start a system test" + } + }, "entity": { "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e8..9edf6e61751 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ if TYPE_CHECKING: class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ class NWSWeather(WeatherEntity): self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cfe..b9109645943 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ class OASATelematicsSensor(SensorEntity): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 810b24dca20..12cb9e25f84 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1,9 +1,11 @@ """The Obihai integration.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import PLATFORMS +from .connectivity import ObihaiConnection +from .const import LOGGER, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -13,6 +15,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + version = entry.version + + LOGGER.debug("Migrating from version %s", version) + if version != 2: + requester = ObihaiConnection( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + await hass.async_add_executor_job(requester.update) + + new_unique_id = await hass.async_add_executor_job( + requester.pyobihai.get_device_mac + ) + hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 2f8dd0075b8..6216fe0b973 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -1,10 +1,14 @@ """Config flow to configure the Obihai integration.""" + from __future__ import annotations +from socket import gaierror, gethostbyname from typing import Any +from pyobihai import PyObihai 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.core import HomeAssistant @@ -16,11 +20,11 @@ from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional( + vol.Required( CONF_USERNAME, default=DEFAULT_USERNAME, ): str, - vol.Optional( + vol.Required( CONF_PASSWORD, default=DEFAULT_PASSWORD, ): str, @@ -28,48 +32,124 @@ DATA_SCHEMA = vol.Schema( ) -async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: +async def async_validate_creds( + hass: HomeAssistant, user_input: dict[str, Any] +) -> PyObihai | None: """Manage Obihai options.""" - return await hass.async_add_executor_job( - validate_auth, - user_input[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) + + if user_input[CONF_USERNAME] and user_input[CONF_PASSWORD]: + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + # Don't bother authenticating if we've already determined the credentials are invalid + return None class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Obihai.""" - VERSION = 1 + VERSION = 2 + discovery_schema: vol.Schema | None = None + _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + ip: str | None = None if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await async_validate_creds(self.hass, user_input): - return self.async_create_entry( - title=user_input[CONF_HOST], - data=user_input, + try: + ip = await self.hass.async_add_executor_job( + gethostbyname, user_input[CONF_HOST] ) - errors["base"] = "cannot_connect" + except gaierror: + errors["base"] = "cannot_connect" - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + if ip: + if pyobihai := await async_validate_creds(self.hass, user_input): + 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=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "invalid_auth" + + data_schema = self.discovery_schema or DATA_SCHEMA return self.async_show_form( step_id="user", errors=errors, - data_schema=data_schema, + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Prepare configuration for a DHCP discovered Obihai.""" + + self._dhcp_discovery_info = discovery_info + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confirm.""" + assert self._dhcp_discovery_info + await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + self._abort_if_unique_id_configured() + + if user_input is None: + credentials = { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_PASSWORD: DEFAULT_PASSWORD, + CONF_USERNAME: DEFAULT_USERNAME, + } + if await async_validate_creds(self.hass, credentials): + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, credentials + ) + else: + self.discovery_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, + { + CONF_HOST: self._dhcp_discovery_info.ip, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + # Show the confirmation dialog + return self.async_show_form( + step_id="dhcp_confirm", + data_schema=self.discovery_schema, + description_placeholders={CONF_HOST: self._dhcp_discovery_info.ip}, + ) + + 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.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - if await async_validate_creds(self.hass, 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={ @@ -79,4 +159,4 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 93eeccd1bb7..071390f1ad9 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -1,4 +1,5 @@ """Support for Obihai Connectivity.""" + from __future__ import annotations from pyobihai import PyObihai @@ -12,6 +13,7 @@ def get_pyobihai( password: str, ) -> PyObihai: """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) @@ -19,16 +21,17 @@ def validate_auth( host: str, username: str, password: str, -) -> bool: +) -> PyObihai | None: """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) login = obi.check_account() if not login: LOGGER.debug("Invalid credentials") - return False + return None - return True + return obi class ObihaiConnection: @@ -53,6 +56,7 @@ class ObihaiConnection: def update(self) -> bool: """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: self.pyobihai = get_pyobihai(self.host, self.username, self.password) diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 939c170f989..2907f3f179d 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -3,6 +3,11 @@ "name": "Obihai", "codeowners": ["@dshokouhi", "@ejpenney"], "config_flow": true, + "dhcp": [ + { + "macaddress": "9CADEF*" + } + ], "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index fb673675ad7..1b91cd60654 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -7,10 +7,19 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" } + }, + "dhcp_confirm": { + "description": "Do you want to set up {host}?", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 45fd04049ad..2c96b79cbeb 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,7 +1,10 @@ """The ONVIF integration.""" +import asyncio +import logging + from httpx import RequestError from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError -from zeep.exceptions import Fault +from zeep.exceptions import Fault, TransportError from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS @@ -13,10 +16,13 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN from .device import ONVIFDevice +from .util import is_auth_error, stringify_onvif_error + +LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -31,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await device.async_setup() + if not entry.data.get(CONF_SNAPSHOT_AUTH): + await async_populate_snapshot_auth(hass, device, entry) except RequestError as err: await device.device.close() raise ConfigEntryNotReady( @@ -38,23 +46,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err except Fault as err: await device.device.close() - # We do no know if the credentials are wrong or the camera is - # still booting up, so we will retry later + if is_auth_error(err): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringify_onvif_error(err)}" + ) from err raise ConfigEntryNotReady( - f"Could not connect to camera, verify credentials are correct: {err}" + f"Could not connect to camera: {stringify_onvif_error(err)}" ) from err except ONVIFError as err: await device.device.close() raise ConfigEntryNotReady( f"Could not setup camera {device.device.host}:{device.device.port}: {err}" ) from err + except asyncio.CancelledError as err: + # After https://github.com/agronholm/anyio/issues/374 is resolved + # this may be able to be removed + await device.device.close() + raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err if not device.available: raise ConfigEntryNotReady() - if not entry.data.get(CONF_SNAPSHOT_AUTH): - await async_populate_snapshot_auth(hass, device, entry) - hass.data[DOMAIN][entry.unique_id] = device device.platforms = [Platform.BUTTON, Platform.CAMERA] @@ -80,7 +92,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] if device.capabilities.events and device.events.started: - await device.events.async_stop() + try: + await device.events.async_stop() + except (ONVIFError, Fault, RequestError, TransportError): + LOGGER.warning("Error while stopping events: %s", device.name) return await hass.config_entries.async_unload_platforms(entry, device.platforms) diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 8f79b43296f..3676e3b6c27 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] entities = { event.uid: ONVIFBinarySensor(event.uid, device) @@ -39,16 +39,20 @@ async def async_setup_entry( ) async_add_entities(entities.values()) + uids_by_platform = device.events.get_uids_by_platform("binary_sensor") @callback - def async_check_entities(): + def async_check_entities() -> None: """Check if we have added an entity for the event.""" - new_entities = [] - for event in device.events.get_platform("binary_sensor"): - if event.uid not in entities: - entities[event.uid] = ONVIFBinarySensor(event.uid, device) - new_entities.append(entities[event.uid]) - async_add_entities(new_entities) + nonlocal uids_by_platform + if not (missing := uids_by_platform.difference(entities)): + return + new_entities: dict[str, ONVIFBinarySensor] = { + uid: ONVIFBinarySensor(uid, device) for uid in missing + } + if new_entities: + entities.update(new_entities) + async_add_entities(new_entities.values()) device.events.async_add_listener(async_check_entities) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 11699731b2f..7a87ec66c83 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,6 +1,8 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" from __future__ import annotations +import asyncio + from haffmpeg.camera import CameraMjpeg from onvif.exceptions import ONVIFError import voluptuous as vol @@ -110,6 +112,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): == HTTP_BASIC_AUTHENTICATION ) self._stream_uri: str | None = None + self._stream_uri_future: asyncio.Future[str] | None = None @property def name(self) -> str: @@ -130,7 +133,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): async def stream_source(self): """Return the stream source.""" - return self._stream_uri + return await self._async_get_stream_uri() async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -158,10 +161,10 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self.device.name, ) - assert self._stream_uri + stream_uri = await self._async_get_stream_uri() return await ffmpeg.async_get_image( self.hass, - self._stream_uri, + stream_uri, extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), width=width, height=height, @@ -173,9 +176,10 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ffmpeg_manager = get_ffmpeg_manager(self.hass) stream = CameraMjpeg(ffmpeg_manager.binary) + stream_uri = await self._async_get_stream_uri() await stream.open_camera( - self._stream_uri, + stream_uri, extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), ) @@ -190,13 +194,27 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): finally: await stream.close() - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - uri_no_auth = await self.device.async_get_stream_uri(self.profile) + async def _async_get_stream_uri(self) -> str: + """Return the stream URI.""" + if self._stream_uri: + return self._stream_uri + if self._stream_uri_future: + return await self._stream_uri_future + loop = asyncio.get_running_loop() + self._stream_uri_future = loop.create_future() + try: + uri_no_auth = await self.device.async_get_stream_uri(self.profile) + except (asyncio.TimeoutError, Exception) as err: + LOGGER.error("Failed to get stream uri: %s", err) + if self._stream_uri_future: + self._stream_uri_future.set_exception(err) + raise url = URL(uri_no_auth) url = url.with_user(self.device.username) url = url.with_password(self.device.password) self._stream_uri = str(url) + self._stream_uri_future.set_result(self._stream_uri) + return self._stream_uri async def async_perform_ptz( self, diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index d9cf28f3e8b..68a4ce52511 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -1,11 +1,11 @@ """Config flow for ONVIF.""" from __future__ import annotations +from collections.abc import Mapping from pprint import pformat from typing import Any from urllib.parse import urlparse -from onvif.exceptions import ONVIFError import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.scope import Scope @@ -13,6 +13,7 @@ from wsdiscovery.service import Service from zeep.exceptions import Fault from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -27,9 +28,19 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import device_registry as dr -from .const import CONF_DEVICE_ID, DEFAULT_ARGUMENTS, DEFAULT_PORT, DOMAIN, LOGGER +from .const import ( + CONF_DEVICE_ID, + DEFAULT_ARGUMENTS, + DEFAULT_PORT, + DOMAIN, + GET_CAPABILITIES_EXCEPTIONS, + LOGGER, +) from .device import get_device +from .util import is_auth_error, stringify_onvif_error CONF_MANUAL_INPUT = "Manually configure ONVIF device" @@ -74,6 +85,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a ONVIF config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry @staticmethod @callback @@ -101,6 +113,68 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required("auto", default=True): bool}), ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication of an existing config entry.""" + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry is not None + self._reauth_entry = reauth_entry + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth.""" + entry = self._reauth_entry + errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] | None = None + if user_input is not None: + entry_data = entry.data + self.onvif_config = entry_data | user_input + errors, description_placeholders = await self.async_setup_profiles( + configure_unique_id=False + ) + if not errors: + hass = self.hass + entry_id = entry.entry_id + hass.config_entries.async_update_entry(entry, data=self.onvif_config) + hass.async_create_task(hass.config_entries.async_reload(entry_id)) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + hass = self.hass + mac = discovery_info.macaddress + registry = dr.async_get(self.hass) + if not ( + device := registry.async_get_device( + identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + ): + return self.async_abort(reason="no_devices_found") + for entry_id in device.config_entries: + if ( + not (entry := hass.config_entries.async_get_entry(entry_id)) + or entry.domain != DOMAIN + or entry.state is config_entries.ConfigEntryState.LOADED + ): + continue + if hass.config_entries.async_update_entry( + entry, data=entry.data | {CONF_HOST: discovery_info.ip} + ): + hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) + return self.async_abort(reason="already_configured") + async def async_step_device(self, user_input=None): """Handle WS-Discovery. @@ -148,15 +222,18 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_configure() - async def async_step_configure(self, user_input=None): + async def async_step_configure( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Device configuration.""" - errors = {} + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} if user_input: self.onvif_config = user_input - try: - return await self.async_setup_profiles() - except Fault: - errors["base"] = "cannot_connect" + errors, description_placeholders = await self.async_setup_profiles() + if not errors: + title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" + return self.async_create_entry(title=title, data=self.onvif_config) def conf(name, default=None): return self.onvif_config.get(name, default) @@ -177,9 +254,12 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ), errors=errors, + description_placeholders=description_placeholders, ) - async def async_setup_profiles(self): + async def async_setup_profiles( + self, configure_unique_id: bool = True + ) -> tuple[dict[str, str], dict[str, str]]: """Fetch ONVIF device profiles.""" LOGGER.debug( "Fetching profiles from ONVIF device %s", pformat(self.onvif_config) @@ -196,7 +276,6 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await device.update_xaddrs() device_mgmt = device.create_devicemgmt_service() - # Get the MAC address to use as the unique ID for the config flow if not self.device_id: try: @@ -210,56 +289,75 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except Fault as fault: if "not implemented" not in fault.message: raise fault - LOGGER.debug( - ( - "Couldn't get network interfaces from ONVIF deivice '%s'." - " Error: %s" - ), + "%s: Could not get network interfaces: %s", self.onvif_config[CONF_NAME], - fault, + stringify_onvif_error(fault), ) - # If no network interfaces are exposed, fallback to serial number if not self.device_id: device_info = await device_mgmt.GetDeviceInformation() self.device_id = device_info.SerialNumber if not self.device_id: - return self.async_abort(reason="no_mac") - - await self.async_set_unique_id(self.device_id, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.onvif_config[CONF_HOST], - CONF_PORT: self.onvif_config[CONF_PORT], - CONF_NAME: self.onvif_config[CONF_NAME], - } - ) + raise AbortFlow(reason="no_mac") + if configure_unique_id: + await self.async_set_unique_id(self.device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.onvif_config[CONF_HOST], + CONF_PORT: self.onvif_config[CONF_PORT], + CONF_NAME: self.onvif_config[CONF_NAME], + CONF_USERNAME: self.onvif_config[CONF_USERNAME], + CONF_PASSWORD: self.onvif_config[CONF_PASSWORD], + } + ) # Verify there is an H264 profile media_service = device.create_media_service() profiles = await media_service.GetProfiles() - h264 = any( + except AttributeError: # Likely an empty document or 404 from the wrong port + LOGGER.debug( + "%s: No ONVIF service found at %s:%s", + self.onvif_config[CONF_NAME], + self.onvif_config[CONF_HOST], + self.onvif_config[CONF_PORT], + exc_info=True, + ) + return {CONF_PORT: "no_onvif_service"}, {} + except Fault as err: + stringified_error = stringify_onvif_error(err) + description_placeholders = {"error": stringified_error} + if is_auth_error(err): + LOGGER.debug( + "%s: Could not authenticate with camera: %s", + self.onvif_config[CONF_NAME], + stringified_error, + ) + return {CONF_PASSWORD: "auth_failed"}, description_placeholders + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringified_error, + exc_info=True, + ) + return {"base": "onvif_error"}, description_placeholders + except GET_CAPABILITIES_EXCEPTIONS as err: + LOGGER.debug( + "%s: Could not determine camera capabilities: %s", + self.onvif_config[CONF_NAME], + stringify_onvif_error(err), + exc_info=True, + ) + return {"base": "onvif_error"}, {"error": stringify_onvif_error(err)} + else: + if not any( profile.VideoEncoderConfiguration and profile.VideoEncoderConfiguration.Encoding == "H264" for profile in profiles - ) - - if not h264: - return self.async_abort(reason="no_h264") - - title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" - return self.async_create_entry(title=title, data=self.onvif_config) - - except ONVIFError as err: - LOGGER.error( - "Couldn't setup ONVIF device '%s'. Error: %s", - self.onvif_config[CONF_NAME], - err, - ) - return self.async_abort(reason="onvif_error") - + ): + raise AbortFlow(reason="no_h264") + return {}, {} finally: await device.close() diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 410088f28df..bfe22eacbd5 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -1,6 +1,10 @@ """Constants for the onvif component.""" import logging +from httpx import RequestError +from onvif.exceptions import ONVIFError +from zeep.exceptions import Fault, TransportError + LOGGER = logging.getLogger(__package__) DOMAIN = "onvif" @@ -36,3 +40,8 @@ GOTOPRESET_MOVE = "GotoPreset" STOP_MOVE = "Stop" SERVICE_PTZ = "ptz" + + +# Some cameras don't support the GetServiceCapabilities call +# and will return a 404 error which is caught by TransportError +GET_CAPABILITIES_EXCEPTIONS = (ONVIFError, Fault, RequestError, TransportError) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index a9f8625521e..78e745645c5 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -6,6 +6,7 @@ from contextlib import suppress import datetime as dt import os import time +from typing import Any from httpx import RequestError import onvif @@ -28,6 +29,7 @@ import homeassistant.util.dt as dt_util from .const import ( ABSOLUTE_MOVE, CONTINUOUS_MOVE, + GET_CAPABILITIES_EXCEPTIONS, GOTOPRESET_MOVE, LOGGER, PAN_FACTOR, @@ -54,6 +56,7 @@ class ONVIFDevice: self.info: DeviceInfo = DeviceInfo() self.capabilities: Capabilities = Capabilities() + self.onvif_capabilities: dict[str, Any] | None = None self.profiles: list[Profile] = [] self.max_resolution: int = 0 self.platforms: list[Platform] = [] @@ -97,17 +100,33 @@ class ONVIFDevice: # Get all device info await self.device.update_xaddrs() + LOGGER.debug("%s: xaddrs = %s", self.name, self.device.xaddrs) + + # Get device capabilities + self.onvif_capabilities = await self.device.get_capabilities() + await self.async_check_date_and_time() # Create event manager assert self.config_entry.unique_id - self.events = EventManager(self.hass, self.device, self.config_entry.unique_id) + self.events = EventManager(self.hass, self.device, self.config_entry, self.name) # Fetch basic device info and capabilities self.info = await self.async_get_device_info() - LOGGER.debug("Camera %s info = %s", self.name, self.info) + LOGGER.debug("%s: camera info = %s", self.name, self.info) + + # + # We need to check capabilities before profiles, because we need the data + # from capabilities to determine profiles correctly. + # + # We no longer initialize events in capabilities to avoid the problem + # where cameras become slow to respond for a bit after starting events, and + # instead we start events last and than update capabilities. + # + LOGGER.debug("%s: fetching initial capabilities", self.name) self.capabilities = await self.async_get_capabilities() - LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + + LOGGER.debug("%s: fetching profiles", self.name) self.profiles = await self.async_get_profiles() LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles) @@ -116,6 +135,7 @@ class ONVIFDevice: raise ONVIFError("No camera profiles found") if self.capabilities.ptz: + LOGGER.debug("%s: creating PTZ service", self.name) self.device.create_ptz_service() # Determine max resolution from profiles @@ -125,6 +145,12 @@ class ONVIFDevice: if profile.video.encoding == "H264" ) + # Start events last since some cameras become slow to respond + # for a bit after starting events + LOGGER.debug("%s: starting events", self.name) + self.capabilities.events = await self.async_start_events() + LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + async def async_stop(self, event=None): """Shut it all down.""" if self.events: @@ -147,22 +173,38 @@ class ONVIFDevice: dt_param.DaylightSavings = bool(time.localtime().tm_isdst) dt_param.UTCDateTime = device_time.UTCDateTime # Retrieve timezone from system - dt_param.TimeZone = str(system_date.astimezone().tzinfo) 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 - LOGGER.debug("SetSystemDateAndTime: %s", dt_param) - await device_mgmt.SetSystemDateAndTime(dt_param) + 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: + timezone_names.append(time_zone.TZ) + timezone_names.append(None) + timezone_max_idx = len(timezone_names) - 1 + LOGGER.debug( + "%s: SetSystemDateAndTime: timezone_names:%s", self.name, timezone_names + ) + for idx, timezone_name in enumerate(timezone_names): + dt_param.TimeZone = timezone_name + LOGGER.debug("%s: SetSystemDateAndTime: %s", self.name, dt_param) + try: + await device_mgmt.SetSystemDateAndTime(dt_param) + LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) + return + except Fault: + if idx == timezone_max_idx: + raise async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" - LOGGER.debug("Setting up the ONVIF device management service") + LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) device_mgmt = self.device.create_devicemgmt_service() - LOGGER.debug("Retrieving current device date/time") + LOGGER.debug("%s: Retrieving current device date/time", self.name) try: system_date = dt_util.utcnow() device_time = await device_mgmt.GetSystemDateAndTime() @@ -174,7 +216,7 @@ class ONVIFDevice: ) return - LOGGER.debug("Device time: %s", device_time) + LOGGER.debug("%s: Device time: %s", self.name, device_time) tzone = dt_util.DEFAULT_TIME_ZONE cdate = device_time.LocalDateTime @@ -185,7 +227,9 @@ class ONVIFDevice: tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone if cdate is None: - LOGGER.warning("Could not retrieve date/time on this camera") + LOGGER.warning( + "%s: Could not retrieve date/time on this camera", self.name + ) else: cam_date = dt.datetime( cdate.Date.Year, @@ -201,7 +245,8 @@ class ONVIFDevice: cam_date_utc = cam_date.astimezone(dt_util.UTC) LOGGER.debug( - "Device date/time: %s | System date/time: %s", + "%s: Device date/time: %s | System date/time: %s", + self.name, cam_date_utc, system_date, ) @@ -209,7 +254,8 @@ class ONVIFDevice: dt_diff = cam_date - system_date self._dt_diff_seconds = dt_diff.total_seconds() - if self._dt_diff_seconds > 5: + # It could be off either direction, so we need to check the absolute value + if abs(self._dt_diff_seconds) > 5: LOGGER.warning( ( "The date/time on %s (UTC) is '%s', " @@ -261,31 +307,46 @@ class ONVIFDevice: async def async_get_capabilities(self): """Obtain information about the available services on the device.""" snapshot = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): media_service = self.device.create_media_service() media_capabilities = await media_service.GetServiceCapabilities() snapshot = media_capabilities and media_capabilities.SnapshotUri - pullpoint = False - with suppress(ONVIFError, Fault, RequestError, XMLParseError): - pullpoint = await self.events.async_start() - ptz = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): self.device.get_definition("ptz") ptz = True imaging = False - with suppress(ONVIFError, Fault, RequestError): + with suppress(*GET_CAPABILITIES_EXCEPTIONS): self.device.create_imaging_service() imaging = True - return Capabilities(snapshot, pullpoint, ptz, imaging) + return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging) + + async def async_start_events(self): + """Start the event handler.""" + with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): + onvif_capabilities = self.onvif_capabilities or {} + pull_point_support = onvif_capabilities.get("Events", {}).get( + "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 False async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" media_service = self.device.create_media_service() - result = await media_service.GetProfiles() + LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr) + try: + result = await media_service.GetProfiles() + except GET_CAPABILITIES_EXCEPTIONS: + LOGGER.debug( + "%s: Could not get profiles from ONVIF device", self.name, exc_info=True + ) + raise profiles: list[Profile] = [] if not isinstance(result, list): @@ -327,7 +388,7 @@ class ONVIFDevice: ptz_service = self.device.create_ptz_service() presets = await ptz_service.GetPresets(profile.token) profile.ptz.presets = [preset.token for preset in presets if preset] - except (Fault, RequestError): + except GET_CAPABILITIES_EXCEPTIONS: # It's OK if Presets aren't supported profile.ptz.presets = [] diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index eb818f53a3a..d7f2c515308 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -28,5 +28,9 @@ async def async_get_config_entry_diagnostics( "capabilities": asdict(device.capabilities), "profiles": [asdict(profile) for profile in device.profiles], } + data["events"] = { + "webhook_manager_state": device.events.webhook_manager.state, + "pullpoint_manager_state": device.events.pullpoint_manager.state, + } return data diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 5bc2a8248fc..851b0f26d1b 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -5,72 +5,105 @@ import asyncio from collections.abc import Callable from contextlib import suppress import datetime as dt -from logging import DEBUG, WARNING -from httpx import RemoteProtocolError, TransportError +from aiohttp.web import Request +from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService -from zeep.exceptions import Fault, XMLParseError +from onvif.client import NotificationManager +from onvif.exceptions import ONVIFError +from zeep.exceptions import Fault, ValidationError, XMLParseError -from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + HassJob, + HomeAssistant, + callback, +) from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util +from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import LOGGER -from .models import Event +from .const import DOMAIN, LOGGER +from .models import Event, PullPointManagerState, WebHookManagerState from .parsers import PARSERS +from .util import stringify_onvif_error -UNHANDLED_TOPICS: set[str] = set() +# Topics in this list are ignored because we do not want to create +# entities for them. +UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) +CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError) SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) +UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) +RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) +# +# We only keep the subscription alive for 3 minutes, and will keep +# renewing it every 1.5 minutes. This is to avoid the camera +# accumulating subscriptions which will be impossible to clean up +# since ONVIF does not provide a way to list existing subscriptions. +# +# If we max out the number of subscriptions, the camera will stop +# sending events to us, and we will not be able to recover until +# the subscriptions expire or the camera is rebooted. +# +SUBSCRIPTION_TIME = dt.timedelta(minutes=3) +SUBSCRIPTION_RELATIVE_TIME = ( + "PT3M" # use relative time since the time on the camera is not reliable +) +SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2 +SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0 - -def _stringify_onvif_error(error: Exception) -> str: - """Stringify ONVIF error.""" - if isinstance(error, Fault): - return error.message or str(error) or "Device sent empty error" - return str(error) - - -def _get_next_termination_time() -> str: - """Get next termination time.""" - return ( - (dt_util.utcnow() + dt.timedelta(days=1)) - .isoformat(timespec="seconds") - .replace("+00:00", "Z") - ) +PULLPOINT_POLL_TIME = dt.timedelta(seconds=60) +PULLPOINT_MESSAGE_LIMIT = 100 +PULLPOINT_COOLDOWN_TIME = 0.75 class EventManager: """ONVIF Event Manager.""" def __init__( - self, hass: HomeAssistant, device: ONVIFCamera, unique_id: str + self, + hass: HomeAssistant, + device: ONVIFCamera, + config_entry: ConfigEntry, + name: str, ) -> None: """Initialize event manager.""" - self.hass: HomeAssistant = hass - self.device: ONVIFCamera = device - self.unique_id: str = unique_id - self.started: bool = False + self.hass = hass + self.device = device + self.config_entry = config_entry + self.unique_id = config_entry.unique_id + self.name = name - self._subscription: ONVIFService = None + self.webhook_manager = WebHookManager(self) + self.pullpoint_manager = PullPointManager(self) + + self._uid_by_platform: dict[str, set[str]] = {} self._events: dict[str, Event] = {} self._listeners: list[CALLBACK_TYPE] = [] - self._unsub_refresh: CALLBACK_TYPE | None = None - - super().__init__() @property - def platforms(self) -> set[str]: - """Return platforms to setup.""" - return {event.platform for event in self._events.values()} + def started(self) -> bool: + """Return True if event manager is started.""" + return ( + self.webhook_manager.state == WebHookManagerState.STARTED + or self.pullpoint_manager.state == PullPointManagerState.STARTED + ) + + @property + def has_listeners(self) -> bool: + """Return if there are listeners.""" + return bool(self._listeners) @callback def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: """Listen for data updates.""" # This is the first listener, set up polling. if not self._listeners: - self.async_schedule_pull() + self.pullpoint_manager.async_schedule_pull_messages() self._listeners.append(update_callback) @@ -87,188 +120,764 @@ class EventManager: if update_callback in self._listeners: self._listeners.remove(update_callback) - if not self._listeners and self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None + if not self._listeners: + self.pullpoint_manager.async_cancel_pull_messages() - async def async_start(self) -> bool: + async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool: """Start polling events.""" - if not await self.device.create_pullpoint_subscription( - {"InitialTerminationTime": _get_next_termination_time()} - ): - return False - - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" + # Always start pull point first, since it will populate the event list + event_via_pull_point = ( + try_pullpoint and await self.pullpoint_manager.async_start() ) - - # Renew immediately - await self.async_renew() - - # Initialize events - pullpoint = self.device.create_pullpoint_service() - with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): - await pullpoint.SetSynchronizationPoint() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} - ) - - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) - - self.started = True - return True + events_via_webhook = try_webhook and await self.webhook_manager.async_start() + return events_via_webhook or event_via_pull_point async def async_stop(self) -> None: """Unsubscribe from events.""" self._listeners = [] - self.started = False + await self.pullpoint_manager.async_stop() + await self.webhook_manager.async_stop() - if not self._subscription: - return - - with suppress(*SUBSCRIPTION_ERRORS): - await self._subscription.Unsubscribe() - self._subscription = None - - async def async_restart(self, _now: dt.datetime | None = None) -> None: - """Restart the subscription assuming the camera rebooted.""" - if not self.started: - return - - if self._subscription: - # Suppressed. The subscription may no longer exist. - try: - await self._subscription.Unsubscribe() - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - LOGGER.debug( - ( - "Failed to unsubscribe ONVIF PullPoint subscription for '%s';" - " This is normal if the device restarted: %s" - ), - self.unique_id, - err, - ) - self._subscription = None - - try: - restarted = await self.async_start() - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - restarted = False - # Device may not support subscriptions so log at debug level - # when we get an XMLParseError - LOGGER.log( - DEBUG if isinstance(err, XMLParseError) else WARNING, - ( - "Failed to restart ONVIF PullPoint subscription for '%s'; " - "Retrying later: %s" - ), - self.unique_id, - _stringify_onvif_error(err), - ) - - if not restarted: - # Try again in a minute - self._unsub_refresh = async_call_later(self.hass, 60, self.async_restart) - elif self._listeners: - LOGGER.debug( - "Restarted ONVIF PullPoint subscription for '%s'", self.unique_id - ) - self.async_schedule_pull() - - async def async_renew(self) -> None: - """Renew subscription.""" - if not self._subscription: - return - - with suppress(*SUBSCRIPTION_ERRORS): - # The first time we renew, we may get a Fault error so we - # suppress it. The subscription will be restarted in - # async_restart later. - await self._subscription.Renew(_get_next_termination_time()) - - def async_schedule_pull(self) -> None: - """Schedule async_pull_messages to run.""" - self._unsub_refresh = async_call_later(self.hass, 1, self.async_pull_messages) - - async def async_pull_messages(self, _now: dt.datetime | None = None) -> None: - """Pull messages from device.""" - if self.hass.state == CoreState.running: - try: - pullpoint = self.device.create_pullpoint_service() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=60)} - ) - - # Renew subscription if less than two hours is left - if ( - dt_util.as_utc(response.TerminationTime) - dt_util.utcnow() - ).total_seconds() < 7200: - await self.async_renew() - except RemoteProtocolError: - # Likely a shutdown event, nothing to see here - return - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: - # Device may not support subscriptions so log at debug level - # when we get an XMLParseError - LOGGER.log( - DEBUG if isinstance(err, XMLParseError) else WARNING, - ( - "Failed to fetch ONVIF PullPoint subscription messages for" - " '%s': %s" - ), - self.unique_id, - _stringify_onvif_error(err), - ) - # Treat errors as if the camera restarted. Assume that the pullpoint - # subscription is no longer valid. - self._unsub_refresh = None - await self.async_restart() - return - - # Parse response - await self.async_parse_messages(response.NotificationMessage) - - # Update entities - for update_callback in self._listeners: - update_callback() - - # Reschedule another pull - if self._listeners: - self.async_schedule_pull() + @callback + def async_callback_listeners(self) -> None: + """Update listeners.""" + for update_callback in self._listeners: + update_callback() # pylint: disable=protected-access async def async_parse_messages(self, messages) -> None: """Parse notification message.""" + unique_id = self.unique_id + assert unique_id is not None for msg in messages: # Guard against empty message if not msg.Topic: continue - topic = msg.Topic._value_1 + # Topic may look like the following + # + # tns1:RuleEngine/CellMotionDetector/Motion//. + # tns1:RuleEngine/CellMotionDetector/Motion + # tns1:RuleEngine/CellMotionDetector/Motion/ + # + # Our parser expects the topic to be + # tns1:RuleEngine/CellMotionDetector/Motion + topic = msg.Topic._value_1.rstrip("/.") + if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: LOGGER.info( - "No registered handler for event from %s: %s", - self.unique_id, + "%s: No registered handler for event from %s: %s", + self.name, + unique_id, msg, ) UNHANDLED_TOPICS.add(topic) continue - event = await parser(self.unique_id, msg) + event = await parser(unique_id, msg) if not event: - LOGGER.info("Unable to parse event from %s: %s", self.unique_id, msg) + LOGGER.info( + "%s: Unable to parse event from %s: %s", self.name, unique_id, msg + ) return + self.get_uids_by_platform(event.platform).add(event.uid) self._events[event.uid] = event - def get_uid(self, uid) -> Event | None: + def get_uid(self, uid: str) -> Event | None: """Retrieve event for given id.""" return self._events.get(uid) def get_platform(self, platform) -> list[Event]: """Retrieve events for given platform.""" return [event for event in self._events.values() if event.platform == platform] + + def get_uids_by_platform(self, platform: str) -> set[str]: + """Retrieve uids for a given platform.""" + if (possible_uids := self._uid_by_platform.get(platform)) is None: + uids: set[str] = set() + self._uid_by_platform[platform] = uids + return uids + return possible_uids + + @callback + def async_webhook_failed(self) -> None: + """Mark webhook as failed.""" + if self.pullpoint_manager.state != PullPointManagerState.PAUSED: + return + LOGGER.debug("%s: Switching to PullPoint for events", self.name) + self.pullpoint_manager.async_resume() + + @callback + def async_webhook_working(self) -> None: + """Mark webhook as working.""" + if self.pullpoint_manager.state != PullPointManagerState.STARTED: + return + LOGGER.debug("%s: Switching to webhook for events", self.name) + self.pullpoint_manager.async_pause() + + @callback + def async_mark_events_stale(self) -> None: + """Mark all events as stale when the subscriptions fail since we are out of sync.""" + self._events.clear() + self.async_callback_listeners() + + +class PullPointManager: + """ONVIF PullPoint Manager. + + If the camera supports webhooks and the webhook is reachable, the pullpoint + manager will keep the pull point subscription alive, but will not poll for + messages unless the webhook fails. + """ + + def __init__(self, event_manager: EventManager) -> None: + """Initialize pullpoint manager.""" + self.state = PullPointManagerState.STOPPED + + self._event_manager = event_manager + self._device = event_manager.device + self._hass = event_manager.hass + self._name = event_manager.name + + self._pullpoint_subscription: ONVIFService = None + self._pullpoint_service: ONVIFService = None + self._pull_lock: asyncio.Lock = asyncio.Lock() + + self._cancel_pull_messages: CALLBACK_TYPE | None = None + self._cancel_pullpoint_renew: CALLBACK_TYPE | None = None + + self._renew_lock: asyncio.Lock = asyncio.Lock() + self._renew_or_restart_job = HassJob( + self._async_renew_or_restart_pullpoint, + f"{self._name}: renew or restart pullpoint", + ) + self._pull_messages_job = HassJob( + self._async_background_pull_messages, + f"{self._name}: pull messages", + ) + + async def async_start(self) -> bool: + """Start pullpoint subscription.""" + assert ( + self.state == PullPointManagerState.STOPPED + ), "PullPoint manager already started" + LOGGER.debug("%s: Starting PullPoint manager", self._name) + if not await self._async_start_pullpoint(): + self.state = PullPointManagerState.FAILED + return False + self.state = PullPointManagerState.STARTED + return True + + @callback + def async_pause(self) -> None: + """Pause pullpoint subscription.""" + LOGGER.debug("%s: Pausing PullPoint manager", self._name) + self.state = PullPointManagerState.PAUSED + self._hass.async_create_task(self._async_cancel_and_unsubscribe()) + + @callback + def async_resume(self) -> None: + """Resume pullpoint subscription.""" + LOGGER.debug("%s: Resuming PullPoint manager", self._name) + self.state = PullPointManagerState.STARTED + self.async_schedule_pullpoint_renew(0.0) + + @callback + def async_schedule_pullpoint_renew(self, delay: float) -> None: + """Schedule PullPoint subscription renewal.""" + self._async_cancel_pullpoint_renew() + self._cancel_pullpoint_renew = async_call_later( + self._hass, + delay, + self._renew_or_restart_job, + ) + + @callback + def async_cancel_pull_messages(self) -> None: + """Cancel the PullPoint task.""" + if self._cancel_pull_messages: + self._cancel_pull_messages() + self._cancel_pull_messages = None + + @callback + def async_schedule_pull_messages(self, delay: float | None = None) -> None: + """Schedule async_pull_messages to run. + + Used as fallback when webhook is not working. + + Must not check if the webhook is working. + """ + self.async_cancel_pull_messages() + if self.state != PullPointManagerState.STARTED: + return + if self._pullpoint_service: + when = delay if delay is not None else PULLPOINT_COOLDOWN_TIME + self._cancel_pull_messages = async_call_later( + self._hass, when, self._pull_messages_job + ) + + async def async_stop(self) -> None: + """Unsubscribe from PullPoint and cancel callbacks.""" + self.state = PullPointManagerState.STOPPED + await self._async_cancel_and_unsubscribe() + + async def _async_start_pullpoint(self) -> bool: + """Start pullpoint subscription.""" + try: + try: + started = await self._async_create_pullpoint_subscription() + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to declare the camera as not supporting PullPoint + # if it just happened to close the connection at the wrong time. + started = await self._async_create_pullpoint_subscription() + except CREATE_ERRORS as err: + LOGGER.debug( + "%s: Device does not support PullPoint service or has too many subscriptions: %s", + self._name, + stringify_onvif_error(err), + ) + return False + + if started: + self.async_schedule_pullpoint_renew(SUBSCRIPTION_RENEW_INTERVAL) + + return started + + async def _async_cancel_and_unsubscribe(self) -> None: + """Cancel and unsubscribe from PullPoint.""" + self._async_cancel_pullpoint_renew() + self.async_cancel_pull_messages() + await self._async_unsubscribe_pullpoint() + + async def _async_renew_or_restart_pullpoint( + self, now: dt.datetime | None = None + ) -> None: + """Renew or start pullpoint subscription.""" + if self._hass.is_stopping or self.state != PullPointManagerState.STARTED: + return + if self._renew_lock.locked(): + LOGGER.debug("%s: PullPoint renew already in progress", self._name) + # Renew is already running, another one will be + # scheduled when the current one is done if needed. + return + async with self._renew_lock: + next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + try: + if ( + await self._async_renew_pullpoint() + or await self._async_restart_pullpoint() + ): + next_attempt = SUBSCRIPTION_RENEW_INTERVAL + finally: + self.async_schedule_pullpoint_renew(next_attempt) + + async def _async_create_pullpoint_subscription(self) -> bool: + """Create pullpoint subscription.""" + + if not await self._device.create_pullpoint_subscription( + {"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME} + ): + LOGGER.debug("%s: Failed to create PullPoint subscription", self._name) + return False + + # Create subscription manager + self._pullpoint_subscription = self._device.create_subscription_service( + "PullPointSubscription" + ) + + # Create the service that will be used to pull messages from the device. + self._pullpoint_service = self._device.create_pullpoint_service() + + # Initialize events + with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): + sync_result = await self._pullpoint_service.SetSynchronizationPoint() + LOGGER.debug("%s: SetSynchronizationPoint: %s", self._name, sync_result) + + # Always schedule an initial pull messages + self.async_schedule_pull_messages(0.0) + + return True + + @callback + def _async_cancel_pullpoint_renew(self) -> None: + """Cancel the pullpoint renew task.""" + if self._cancel_pullpoint_renew: + self._cancel_pullpoint_renew() + self._cancel_pullpoint_renew = None + + async def _async_restart_pullpoint(self) -> bool: + """Restart the subscription assuming the camera rebooted.""" + self.async_cancel_pull_messages() + await self._async_unsubscribe_pullpoint() + restarted = await self._async_start_pullpoint() + if restarted and self._event_manager.has_listeners: + LOGGER.debug("%s: Restarted PullPoint subscription", self._name) + self.async_schedule_pull_messages(0.0) + return restarted + + async def _async_unsubscribe_pullpoint(self) -> None: + """Unsubscribe the pullpoint subscription.""" + if ( + not self._pullpoint_subscription + or self._pullpoint_subscription.transport.client.is_closed + ): + return + LOGGER.debug("%s: Unsubscribing from PullPoint", self._name) + try: + await self._pullpoint_subscription.Unsubscribe() + except UNSUBSCRIBE_ERRORS as err: + LOGGER.debug( + ( + "%s: Failed to unsubscribe PullPoint subscription;" + " This is normal if the device restarted: %s" + ), + self._name, + stringify_onvif_error(err), + ) + self._pullpoint_subscription = None + + async def _async_renew_pullpoint(self) -> bool: + """Renew the PullPoint subscription.""" + if ( + not self._pullpoint_subscription + or self._pullpoint_subscription.transport.client.is_closed + ): + return False + try: + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + try: + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to mark events as stale + # if it just happened to close the connection at the wrong time. + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + LOGGER.debug("%s: Renewed PullPoint subscription", self._name) + return True + except RENEW_ERRORS as err: + self._event_manager.async_mark_events_stale() + LOGGER.debug( + "%s: Failed to renew PullPoint subscription; %s", + self._name, + stringify_onvif_error(err), + ) + return False + + async def _async_pull_messages_with_lock(self) -> bool: + """Pull messages from device while holding the lock. + + This function must not be called directly, it should only + be called from _async_pull_messages. + + Returns True if the subscription is working. + + Returns False if the subscription is not working and should be restarted. + """ + assert self._pull_lock.locked(), "Pull lock must be held" + assert self._pullpoint_service is not None, "PullPoint service does not exist" + event_manager = self._event_manager + LOGGER.debug( + "%s: Pulling PullPoint messages timeout=%s limit=%s", + self._name, + PULLPOINT_POLL_TIME, + PULLPOINT_MESSAGE_LIMIT, + ) + try: + response = await self._pullpoint_service.PullMessages( + { + "MessageLimit": PULLPOINT_MESSAGE_LIMIT, + "Timeout": PULLPOINT_POLL_TIME, + } + ) + except RemoteProtocolError as err: + # Either a shutdown event or the camera closed the connection. Because + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal. Some + # cameras may close the connection if there are no messages to pull. + LOGGER.debug( + "%s: PullPoint subscription encountered a remote protocol error " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + return True + except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: + # Device may not support subscriptions so log at debug level + # when we get an XMLParseError + LOGGER.debug( + "%s: Failed to fetch PullPoint subscription messages: %s", + self._name, + stringify_onvif_error(err), + ) + # Treat errors as if the camera restarted. Assume that the pullpoint + # subscription is no longer valid. + return False + + if self.state != PullPointManagerState.STARTED: + # If the webhook became started working during the long poll, + # and we got paused, our data is stale and we should not process it. + LOGGER.debug( + "%s: PullPoint is paused (likely due to working webhook), skipping PullPoint messages", + self._name, + ) + return True + + # Parse response + if (notification_message := response.NotificationMessage) and ( + number_of_events := len(notification_message) + ): + LOGGER.debug( + "%s: continuous PullMessages: %s event(s)", + self._name, + number_of_events, + ) + await event_manager.async_parse_messages(notification_message) + event_manager.async_callback_listeners() + else: + LOGGER.debug("%s: continuous PullMessages: no events", self._name) + + return True + + @callback + def _async_background_pull_messages(self, _now: dt.datetime | None = None) -> None: + """Pull messages from device in the background.""" + self._cancel_pull_messages = None + self._hass.async_create_background_task( + self._async_pull_messages(), + f"{self._name} background pull messages", + ) + + async def _async_pull_messages(self) -> None: + """Pull messages from device.""" + event_manager = self._event_manager + + if self._pull_lock.locked(): + # Pull messages if the lock is not already locked + # any pull will do, so we don't need to wait for the lock + LOGGER.debug( + "%s: PullPoint subscription is already locked, skipping pull", + self._name, + ) + return + + async with self._pull_lock: + # Before we pop out of the lock we always need to schedule the next pull + # or call async_schedule_pullpoint_renew if the pull fails so the pull + # loop continues. + try: + if self._hass.state == CoreState.running: + if not await self._async_pull_messages_with_lock(): + self.async_schedule_pullpoint_renew(0.0) + return + finally: + if event_manager.has_listeners: + self.async_schedule_pull_messages() + + +class WebHookManager: + """Manage ONVIF webhook subscriptions. + + If the camera supports webhooks, we will use that instead of + pullpoint subscriptions as soon as we detect that the camera + can reach our webhook. + """ + + def __init__(self, event_manager: EventManager) -> None: + """Initialize webhook manager.""" + self.state = WebHookManagerState.STOPPED + + self._event_manager = event_manager + self._device = event_manager.device + self._hass = event_manager.hass + self._webhook_unique_id = f"{DOMAIN}_{event_manager.config_entry.entry_id}" + self._name = event_manager.name + + self._webhook_url: str | None = None + + self._webhook_subscription: ONVIFService | None = None + self._notification_manager: NotificationManager | None = None + + self._cancel_webhook_renew: CALLBACK_TYPE | None = None + self._renew_lock = asyncio.Lock() + self._renew_or_restart_job = HassJob( + self._async_renew_or_restart_webhook, + f"{self._name}: renew or restart webhook", + ) + + async def async_start(self) -> bool: + """Start polling events.""" + LOGGER.debug("%s: Starting webhook manager", self._name) + assert ( + self.state == WebHookManagerState.STOPPED + ), "Webhook manager already started" + assert self._webhook_url is None, "Webhook already registered" + self._async_register_webhook() + if not await self._async_start_webhook(): + self.state = WebHookManagerState.FAILED + return False + self.state = WebHookManagerState.STARTED + return True + + async def async_stop(self) -> None: + """Unsubscribe from events.""" + self.state = WebHookManagerState.STOPPED + self._async_cancel_webhook_renew() + await self._async_unsubscribe_webhook() + self._async_unregister_webhook() + + @callback + def _async_schedule_webhook_renew(self, delay: float) -> None: + """Schedule webhook subscription renewal.""" + self._async_cancel_webhook_renew() + self._cancel_webhook_renew = async_call_later( + self._hass, + delay, + self._renew_or_restart_job, + ) + + async def _async_create_webhook_subscription(self) -> None: + """Create webhook subscription.""" + LOGGER.debug( + "%s: Creating webhook subscription with URL: %s", + self._name, + self._webhook_url, + ) + self._notification_manager = self._device.create_notification_manager( + { + "InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME, + "ConsumerReference": {"Address": self._webhook_url}, + } + ) + try: + self._webhook_subscription = await self._notification_manager.setup() + except ValidationError as err: + # This should only happen if there is a problem with the webhook URL + # that is causing it to not be well formed. + LOGGER.exception( + "%s: validation error while creating webhook subscription: %s", + self._name, + err, + ) + raise + await self._notification_manager.start() + LOGGER.debug( + "%s: Webhook subscription created with URL: %s", + self._name, + self._webhook_url, + ) + + async def _async_start_webhook(self) -> bool: + """Start webhook.""" + try: + try: + await self._async_create_webhook_subscription() + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to declare the camera as not supporting webhooks + # if it just happened to close the connection at the wrong time. + await self._async_create_webhook_subscription() + except CREATE_ERRORS as err: + self._event_manager.async_webhook_failed() + LOGGER.debug( + "%s: Device does not support notification service or too many subscriptions: %s", + self._name, + stringify_onvif_error(err), + ) + return False + + self._async_schedule_webhook_renew(SUBSCRIPTION_RENEW_INTERVAL) + return True + + async def _async_restart_webhook(self) -> bool: + """Restart the webhook subscription assuming the camera rebooted.""" + await self._async_unsubscribe_webhook() + return await self._async_start_webhook() + + async def _async_renew_webhook(self) -> bool: + """Renew webhook subscription.""" + if ( + not self._webhook_subscription + or self._webhook_subscription.transport.client.is_closed + ): + return False + try: + try: + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + except RequestError: + # + # We should only need to retry on RemoteProtocolError but some cameras + # are flaky and sometimes do not respond to the Renew request so we + # retry on RequestError as well. + # + # For RemoteProtocolError: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal and try again + # once since we do not want to mark events as stale + # if it just happened to close the connection at the wrong time. + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + LOGGER.debug("%s: Renewed Webhook subscription", self._name) + return True + except RENEW_ERRORS as err: + self._event_manager.async_mark_events_stale() + LOGGER.debug( + "%s: Failed to renew webhook subscription %s", + self._name, + stringify_onvif_error(err), + ) + return False + + async def _async_renew_or_restart_webhook( + self, now: dt.datetime | None = None + ) -> None: + """Renew or start webhook subscription.""" + if self._hass.is_stopping or self.state != WebHookManagerState.STARTED: + return + if self._renew_lock.locked(): + LOGGER.debug("%s: Webhook renew already in progress", self._name) + # Renew is already running, another one will be + # scheduled when the current one is done if needed. + return + async with self._renew_lock: + next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + try: + if ( + await self._async_renew_webhook() + or await self._async_restart_webhook() + ): + next_attempt = SUBSCRIPTION_RENEW_INTERVAL + finally: + self._async_schedule_webhook_renew(next_attempt) + + @callback + def _async_register_webhook(self) -> None: + """Register the webhook for motion events.""" + LOGGER.debug("%s: Registering webhook: %s", self._name, self._webhook_unique_id) + + try: + base_url = get_url(self._hass, prefer_external=False) + except NoURLAvailableError: + try: + base_url = get_url(self._hass, prefer_external=True) + except NoURLAvailableError: + return + + webhook_id = self._webhook_unique_id + self._async_unregister_webhook() + webhook.async_register( + self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook + ) + webhook_path = webhook.async_generate_path(webhook_id) + self._webhook_url = f"{base_url}{webhook_path}" + LOGGER.debug("%s: Registered webhook: %s", self._name, webhook_id) + + @callback + def _async_unregister_webhook(self): + """Unregister the webhook for motion events.""" + LOGGER.debug( + "%s: Unregistering webhook %s", self._name, self._webhook_unique_id + ) + webhook.async_unregister(self._hass, self._webhook_unique_id) + self._webhook_url = None + + async def _async_handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> None: + """Handle incoming webhook.""" + content: bytes | None = None + try: + content = await request.read() + except ConnectionResetError as ex: + LOGGER.error("Error reading webhook: %s", ex) + return + except asyncio.CancelledError as ex: + LOGGER.error("Error reading webhook: %s", ex) + raise + finally: + self._hass.async_create_background_task( + self._async_process_webhook(hass, webhook_id, content), + f"ONVIF event webhook for {self._name}", + ) + + async def _async_process_webhook( + self, hass: HomeAssistant, webhook_id: str, content: bytes | None + ) -> None: + """Process incoming webhook data in the background.""" + event_manager = self._event_manager + if content is None: + # webhook is marked as not working as something + # went wrong. We will mark it as working again + # when we receive a valid notification. + event_manager.async_webhook_failed() + return + if not self._notification_manager: + LOGGER.debug( + "%s: Received webhook before notification manager is setup", self._name + ) + return + if not (result := self._notification_manager.process(content)): + LOGGER.debug("%s: Failed to process webhook %s", self._name, webhook_id) + return + LOGGER.debug( + "%s: Processed webhook %s with %s event(s)", + self._name, + webhook_id, + len(result.NotificationMessage), + ) + event_manager.async_webhook_working() + await event_manager.async_parse_messages(result.NotificationMessage) + event_manager.async_callback_listeners() + + @callback + def _async_cancel_webhook_renew(self) -> None: + """Cancel the webhook renew task.""" + if self._cancel_webhook_renew: + self._cancel_webhook_renew() + self._cancel_webhook_renew = None + + async def _async_unsubscribe_webhook(self) -> None: + """Unsubscribe from the webhook.""" + if ( + not self._webhook_subscription + or self._webhook_subscription.transport.client.is_closed + ): + return + LOGGER.debug("%s: Unsubscribing from webhook", self._name) + try: + await self._webhook_subscription.Unsubscribe() + except UNSUBSCRIBE_ERRORS as err: + LOGGER.debug( + ( + "%s: Failed to unsubscribe webhook subscription;" + " This is normal if the device restarted: %s" + ), + self._name, + stringify_onvif_error(err), + ) + self._webhook_subscription = None diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index aa06d9c028d..17e7f1f0f29 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@hunterjm"], "config_flow": true, "dependencies": ["ffmpeg"], + "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.11", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index 9f0ca2da66d..64edc85f3d1 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum from typing import Any from homeassistant.const import EntityCategory @@ -78,3 +79,20 @@ class Event: value: Any = None entity_category: EntityCategory | None = None entity_enabled: bool = True + + +class PullPointManagerState(Enum): + """States for the pullpoint manager.""" + + STOPPED = 0 # Not running or not supported + STARTED = 1 # Running and renewing + PAUSED = 2 # Switched to webhook, but can resume + FAILED = 3 # Failed to do initial subscription + + +class WebHookManagerState(Enum): + """States for the webhook manager.""" + + STOPPED = 0 + STARTED = 1 + FAILED = 2 # Failed to do initial subscription diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 08446d8fab9..443254e125a 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -301,6 +301,106 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: return None +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") +# pylint: disable=protected-access +async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Pet Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") +# pylint: disable=protected-access +async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") +# pylint: disable=protected-access +async def async_parse_person_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Person Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") +# pylint: disable=protected-access +async def async_parse_face_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Face Detection", + "binary_sensor", + "motion", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + @PARSERS.register("tns1:Device/Trigger/DigitalInput") # pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: @@ -511,3 +611,67 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: ) except (AttributeError, KeyError): return None + + +@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") +# pylint: disable=protected-access +async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/LineDetector/Crossed + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + "Line Detector Crossed", + "sensor", + None, + None, + msg.Message._value_1.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) + except (AttributeError, KeyError): + return None + + +@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") +# pylint: disable=protected-access +async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/CountAggregation/Counter + """ + try: + video_source = "" + video_analytics = "" + rule = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + "Count Aggregation Counter", + "sensor", + None, + None, + msg.Message._value_1.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) + except (AttributeError, KeyError): + return None diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 2fb7402be28..67da0ed979d 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -23,7 +23,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] entities = { event.uid: ONVIFSensor(event.uid, device) @@ -36,16 +36,20 @@ async def async_setup_entry( entities[entry.unique_id] = ONVIFSensor(entry.unique_id, device, entry) async_add_entities(entities.values()) + uids_by_platform = device.events.get_uids_by_platform("sensor") @callback - def async_check_entities(): + def async_check_entities() -> None: """Check if we have added an entity for the event.""" - new_entities = [] - for event in device.events.get_platform("sensor"): - if event.uid not in entities: - entities[event.uid] = ONVIFSensor(event.uid, device) - new_entities.append(entities[event.uid]) - async_add_entities(new_entities) + nonlocal uids_by_platform + if not (missing := uids_by_platform.difference(entities)): + return + new_entities: dict[str, ONVIFSensor] = { + uid: ONVIFSensor(uid, device) for uid in missing + } + if new_entities: + entities.update(new_entities) + async_add_entities(new_entities.values()) device.events.async_add_listener(async_check_entities) @@ -84,6 +88,7 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor): @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" + assert self._attr_unique_id is not None if (event := self.device.events.get_uid(self._attr_unique_id)) is not None: return event.value return self._attr_native_value diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 210027e96e5..55413e4bf6c 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -2,12 +2,16 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "onvif_error": "Error setting up ONVIF device. Check logs for more information.", "no_h264": "There were no H264 streams available. Check the profile configuration on your device.", - "no_mac": "Could not configure unique ID for ONVIF device." + "no_mac": "Could not configure unique ID for ONVIF device.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "onvif_error": "Error setting up ONVIF device: {error}. Check logs for more information.", + "auth_failed": "Could not authenticate: {error}", + "no_onvif_service": "No ONVIF service found. Check that the port number is correct.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { @@ -40,6 +44,13 @@ "data": { "include": "Create camera entity" } + }, + "reauth_confirm": { + "title": "Reauthenticate the ONVIF device", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } } }, diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index c72a826a79c..4f7de67386b 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -28,6 +28,7 @@ class ONVIFSwitchEntityDescriptionMixin: ] turn_on_data: Any turn_off_data: Any + supported_fn: Callable[[ONVIFDevice], bool] @dataclass @@ -46,6 +47,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data={"Focus": {"AutoFocusMode": "MANUAL"}}, turn_on_fn=lambda device: device.async_set_imaging_settings, turn_off_fn=lambda device: device.async_set_imaging_settings, + supported_fn=lambda device: device.capabilities.imaging, ), ONVIFSwitchEntityDescription( key="ir_lamp", @@ -55,6 +57,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data={"IrCutFilter": "ON"}, turn_on_fn=lambda device: device.async_set_imaging_settings, turn_off_fn=lambda device: device.async_set_imaging_settings, + supported_fn=lambda device: device.capabilities.imaging, ), ONVIFSwitchEntityDescription( key="wiper", @@ -64,6 +67,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( turn_off_data="tt:Wiper|Off", turn_on_fn=lambda device: device.async_run_aux_command, turn_off_fn=lambda device: device.async_run_aux_command, + supported_fn=lambda device: device.capabilities.ptz, ), ) @@ -76,7 +80,11 @@ async def async_setup_entry( """Set up a ONVIF switch platform.""" device = hass.data[DOMAIN][config_entry.unique_id] - async_add_entities(ONVIFSwitch(device, description) for description in SWITCHES) + async_add_entities( + ONVIFSwitch(device, description) + for description in SWITCHES + if description.supported_fn(device) + ) class ONVIFSwitch(ONVIFBaseEntity, SwitchEntity): diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py new file mode 100644 index 00000000000..978473caa24 --- /dev/null +++ b/homeassistant/components/onvif/util.py @@ -0,0 +1,54 @@ +"""ONVIF util.""" +from __future__ import annotations + +from typing import Any + +from zeep.exceptions import Fault + + +def extract_subcodes_as_strings(subcodes: Any) -> list[str]: + """Stringify ONVIF subcodes.""" + if isinstance(subcodes, list): + return [code.text if hasattr(code, "text") else str(code) for code in subcodes] + return [str(subcodes)] + + +def stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + message = error.message + if error.detail: + # 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: + message += f" (code:{error.code})" + if error.subcodes: + message += ( + f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})" + ) + if error.actor: + message += f" (actor:{error.actor})" + else: + message = str(error) + return message or "Device sent empty error" + + +def is_auth_error(error: Exception) -> bool: + """Return True if error is an authentication error. + + Most of the tested cameras do not return a proper error code when + authentication fails, so we need to check the error message as well. + """ + if not isinstance(error, Fault): + return False + return ( + any( + "NotAuthorized" in code + for code in extract_subcodes_as_strings(error.subcodes) + ) + or "auth" in stringify_onvif_error(error).lower() + ) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 6f76142106a..c1b569ce9e1 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,13 +3,14 @@ from __future__ import annotations from functools import partial import logging +from typing import Literal import openai from openai import error from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +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 @@ -70,6 +71,11 @@ class OpenAIAgent(conversation.AbstractConversationAgent): """Return the attribution.""" return {"name": "Powered by OpenAI", "url": "https://www.openai.com"} + @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: diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 892d794bcaf..b391f531eb1 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -72,9 +72,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index f7af4618a9d..9583e759bd2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -11,9 +11,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "options": { diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb91580..0e918103cd2 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ class OpenSenseMapQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index aebf1e26c33..3efe911b27f 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -142,7 +142,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def register_services(hass): +def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( { @@ -260,9 +260,7 @@ def register_services(hass): """Reset the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] mode_rst = gw_vars.OTGW_MODE_RESET - status = await gw_dev.gateway.set_mode(mode_rst) - gw_dev.status = status - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_mode(mode_rst) hass.services.async_register( DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema @@ -283,10 +281,7 @@ def register_services(hass): async def set_control_setpoint(call: ServiceCall) -> None: """Set the control setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_CONTROL_SETPOINT - value = await gw_dev.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, @@ -298,10 +293,7 @@ def register_services(hass): async def set_dhw_ovrd(call: ServiceCall) -> None: """Set the domestic hot water override on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.OTGW_DHW_OVRD - value = await gw_dev.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_hot_water_ovrd(call.data[ATTR_DHW_OVRD]) hass.services.async_register( DOMAIN, @@ -313,10 +305,7 @@ def register_services(hass): async def set_dhw_setpoint(call: ServiceCall) -> None: """Set the domestic hot water setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_DHW_SETPOINT - value = await gw_dev.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_dhw_setpoint(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, @@ -341,10 +330,7 @@ def register_services(hass): gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] gpio_id = call.data[ATTR_ID] gpio_mode = call.data[ATTR_MODE] - mode = await gw_dev.gateway.set_gpio_mode(gpio_id, gpio_mode) - gpio_var = getattr(gw_vars, f"OTGW_GPIO_{gpio_id}") - gw_dev.status.update({gpio_var: mode}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_gpio_mode(gpio_id, gpio_mode) hass.services.async_register( DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode, service_set_gpio_mode_schema @@ -355,10 +341,7 @@ def register_services(hass): gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] led_id = call.data[ATTR_ID] led_mode = call.data[ATTR_MODE] - mode = await gw_dev.gateway.set_led_mode(led_id, led_mode) - led_var = getattr(gw_vars, f"OTGW_LED_{led_id}") - gw_dev.status.update({led_var: mode}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_led_mode(led_id, led_mode) hass.services.async_register( DOMAIN, SERVICE_SET_LED_MODE, set_led_mode, service_set_led_mode_schema @@ -367,14 +350,11 @@ def register_services(hass): async def set_max_mod(call: ServiceCall) -> None: """Set the max modulation level.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD level = call.data[ATTR_LEVEL] if level == -1: # Backend only clears setting on non-numeric values. level = "-" - value = await gw_dev.gateway.set_max_relative_mod(level) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_max_relative_mod(level) hass.services.async_register( DOMAIN, SERVICE_SET_MAX_MOD, set_max_mod, service_set_max_mod_schema @@ -383,10 +363,7 @@ def register_services(hass): async def set_outside_temp(call: ServiceCall) -> None: """Provide the outside temperature to the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.DATA_OUTSIDE_TEMP - value = await gw_dev.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_outside_temp(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, SERVICE_SET_OAT, set_outside_temp, service_set_oat_schema @@ -395,10 +372,7 @@ def register_services(hass): async def set_setback_temp(call: ServiceCall) -> None: """Set the OpenTherm Gateway SetBack temperature.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] - gw_var = gw_vars.OTGW_SB_TEMP - value = await gw_dev.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE]) - gw_dev.status.update({gw_var: value}) - async_dispatcher_send(hass, gw_dev.update_signal, gw_dev.status) + await gw_dev.gateway.set_setback_temp(call.data[ATTR_TEMPERATURE]) hass.services.async_register( DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp, service_set_sb_temp_schema @@ -416,7 +390,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class OpenThermGatewayDevice: """OpenTherm Gateway device class.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the OpenTherm Gateway.""" self.hass = hass self.device_path = config_entry.data[CONF_DEVICE] @@ -424,19 +398,19 @@ class OpenThermGatewayDevice: self.name = config_entry.data[CONF_NAME] self.climate_config = config_entry.options self.config_entry_id = config_entry.entry_id - self.status = {} + self.status = gw_vars.DEFAULT_STATUS self.update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_update" self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_options_update" self.gateway = pyotgw.OpenThermGateway() self.gw_version = None - async def cleanup(self, event=None): + async def cleanup(self, event=None) -> None: """Reset overrides on the gateway.""" await self.gateway.set_control_setpoint(0) await self.gateway.set_max_relative_mod("-") await self.gateway.disconnect() - async def connect_and_subscribe(self): + async def connect_and_subscribe(self) -> None: """Connect to serial device and subscribe report handler.""" self.status = await self.gateway.connect(self.device_path) if not self.status: diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 434b9026ae2..32842ad6cc7 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -98,6 +98,10 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): for current_entry in current_entries: if current_entry.source != SOURCE_HASSIO: continue + if current_entry.unique_id != discovery_info.uuid: + self.hass.config_entries.async_update_entry( + current_entry, unique_id=discovery_info.uuid + ) current_url = yarl.URL(current_entry.data["url"]) if ( current_url.host != config["host"] @@ -116,7 +120,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc) return self.async_abort(reason="unknown") - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(discovery_info.uuid) return self.async_create_entry( title="Open Thread Border Router", data=config_entry_data, diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f1..7c7c30df970 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ DEFAULT_NAME = "OTP Sensor" TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ class TOTPSensor(SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index a40bd731a0f..b7416711e77 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from pyoverkiz.enums.ui import UIClass, UIWidget @@ -15,12 +15,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from .const import DOMAIN from .entity import OverkizDescriptiveEntity @@ -107,19 +107,6 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ ), entity_category=EntityCategory.CONFIG, ), - OverkizSwitchDescription( - key=UIWidget.DYNAMIC_SHUTTER, - name="Silent mode", - turn_on=OverkizCommand.ACTIVATE_OPTION, - turn_on_args=OverkizCommandParam.SILENCE, - turn_off=OverkizCommand.DEACTIVATE_OPTION, - turn_off_args=OverkizCommandParam.SILENCE, - is_on=lambda select_state: ( - OverkizCommandParam.SILENCE - in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS)) - ), - icon="mdi:feather", - ), ] SUPPORTED_DEVICES = { @@ -136,13 +123,7 @@ async def async_setup_entry( data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] entities: list[OverkizSwitch] = [] - for device in data.coordinator.data.values(): - if ( - device.widget in IGNORED_OVERKIZ_DEVICES - or device.ui_class in IGNORED_OVERKIZ_DEVICES - ): - continue - + for device in data.platforms[Platform.SWITCH]: if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( device.ui_class ): diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd8..560493888d4 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) diff --git a/homeassistant/components/panasonic_viera/strings.json b/homeassistant/components/panasonic_viera/strings.json index a04f942dafa..0947b1ad0d4 100644 --- a/homeassistant/components/panasonic_viera/strings.json +++ b/homeassistant/components/panasonic_viera/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Set up your TV", - "description": "Enter your Panasonic Viera TV's [%key:common::config_flow::data::ip%]", + "description": "Enter your Panasonic Viera TV's IP address", "data": { "host": "[%key:common::config_flow::data::ip%]", "name": "[%key:common::config_flow::data::name%]" @@ -11,7 +11,7 @@ }, "pairing": { "title": "Pairing", - "description": "Enter the [%key:common::config_flow::data::pin%] displayed on your TV", + "description": "Enter the PIN displayed on your TV", "data": { "pin": "[%key:common::config_flow::data::pin%]" } @@ -19,7 +19,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_pin_code": "The [%key:common::config_flow::data::pin%] you entered was invalid" + "invalid_pin_code": "The PIN you entered was invalid" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index a5e56d00731..fe6925b4847 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -56,6 +56,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_SOURCE = "source" ATTR_USER_ID = "user_id" +ATTR_DEVICE_TRACKERS = "device_trackers" CONF_DEVICE_TRACKERS = "device_trackers" CONF_USER_ID = "user_id" @@ -188,7 +189,7 @@ class PersonStore(Store): return {"items": old_data["persons"]} -class PersonStorageCollection(collection.StorageCollection): +class PersonStorageCollection(collection.DictStorageCollection): """Person collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -197,15 +198,14 @@ class PersonStorageCollection(collection.StorageCollection): def __init__( self, store: Store, - logger: logging.Logger, id_manager: collection.IDManager, yaml_collection: collection.YamlCollection, ) -> None: """Initialize a person storage collection.""" - super().__init__(store, logger, id_manager) + super().__init__(store, id_manager) self.yaml_collection = yaml_collection - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused onboarding to create invalid person objects. @@ -271,16 +271,16 @@ class PersonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) user_id = update_data.get(CONF_USER_ID) - if user_id is not None and user_id != data.get(CONF_USER_ID): + if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) - return {**data, **update_data} + return {**item, **update_data} async def _validate_user_id(self, user_id): """Validate the used user_id.""" @@ -337,7 +337,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) storage_collection = PersonStorageCollection( PersonStore(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, yaml_collection, ) @@ -356,7 +355,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = (yaml_collection, storage_collection, entity_component) - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass, create_list=False) @@ -448,6 +447,7 @@ class Person(collection.CollectionEntity, RestoreEntity): data[ATTR_SOURCE] = self._source if (user_id := self._config.get(CONF_USER_ID)) is not None: data[ATTR_USER_ID] = user_id + data[ATTR_DEVICE_TRACKERS] = self.device_trackers return data @property diff --git a/homeassistant/components/person/recorder.py b/homeassistant/components/person/recorder.py new file mode 100644 index 00000000000..7c0fdf52258 --- /dev/null +++ b/homeassistant/components/person/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_DEVICE_TRACKERS + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude large and chatty update attributes from being recorded.""" + return {ATTR_DEVICE_TRACKERS} diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 8ee8c3a56a2..8a8915541d8 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -6,6 +6,23 @@ "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" + }, + "state_attributes": { + "device_trackers": { + "name": "Device trackers" + }, + "gps_accuracy": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::gps_accuracy::name%]" + }, + "latitude": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::latitude::name%]" + }, + "longitude": { + "name": "[%key:component::device_tracker::entity_component::_::state_attributes::longitude::name%]" + }, + "source": { + "name": "Source" + } } } } diff --git a/homeassistant/components/pi_hole/diagnostics.py b/homeassistant/components/pi_hole/diagnostics.py new file mode 100644 index 00000000000..8b3c32b0ac2 --- /dev/null +++ b/homeassistant/components/pi_hole/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for the Pi-hole integration.""" +from __future__ import annotations + +from typing import Any + +from hole import Hole + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from .const import DATA_KEY_API, DOMAIN + +TO_REDACT = {CONF_API_KEY} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + api: Hole = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": api.data, + "versions": api.versions, + } diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index a997060eb58..eb12811722b 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -17,7 +17,7 @@ } }, "reauth_confirm": { - "title": "PI-Hole [%key:common::config_flow::title::reauth%]", + "title": "Reauthenticate PI-Hole", "description": "Please enter a new api key for PI-Hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 85a7acadaeb..7e983321f3d 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,16 +1,6 @@ """Constants for the Picnic integration.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Literal - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription -from homeassistant.const import CURRENCY_EURO -from homeassistant.helpers.typing import StateType -from homeassistant.util import dt as dt_util - DOMAIN = "picnic" CONF_API = "api" @@ -49,163 +39,3 @@ SENSOR_NEXT_DELIVERY_ETA_START = "next_delivery_eta_start" SENSOR_NEXT_DELIVERY_ETA_END = "next_delivery_eta_end" SENSOR_NEXT_DELIVERY_SLOT_START = "next_delivery_slot_start" SENSOR_NEXT_DELIVERY_SLOT_END = "next_delivery_slot_end" - - -@dataclass -class PicnicRequiredKeysMixin: - """Mixin for required keys.""" - - data_type: Literal[ - "cart_data", "slot_data", "next_delivery_data", "last_order_data" - ] - value_fn: Callable[[Any], StateType | datetime] - - -@dataclass -class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): - """Describes Picnic sensor entity.""" - - entity_registry_enabled_default: bool = False - - -SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( - PicnicSensorEntityDescription( - key=SENSOR_CART_ITEMS_COUNT, - icon="mdi:format-list-numbered", - data_type="cart_data", - value_fn=lambda cart: cart.get("total_count", 0), - ), - PicnicSensorEntityDescription( - key=SENSOR_CART_TOTAL_PRICE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:currency-eur", - entity_registry_enabled_default=True, - data_type="cart_data", - value_fn=lambda cart: cart.get("total_price", 0) / 100, - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_start"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_end"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-alert-outline", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("cut_off_time"))), - ), - PicnicSensorEntityDescription( - key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:currency-eur", - entity_registry_enabled_default=True, - data_type="slot_data", - value_fn=lambda slot: ( - slot["minimum_order_value"] / 100 - if slot.get("minimum_order_value") - else None - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("window_start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("window_end")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_STATUS, - icon="mdi:list-status", - data_type="last_order_data", - value_fn=lambda last_order: last_order.get("status"), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-alert-outline", - entity_registry_enabled_default=True, - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("slot", {}).get("cut_off_time")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_DELIVERY_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:timeline-clock", - entity_registry_enabled_default=True, - data_type="last_order_data", - value_fn=lambda last_order: dt_util.parse_datetime( - str(last_order.get("delivery_time", {}).get("start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_LAST_ORDER_TOTAL_PRICE, - native_unit_of_measurement=CURRENCY_EURO, - icon="mdi:cash-marker", - data_type="last_order_data", - value_fn=lambda last_order: last_order.get("total_price", 0) / 100, - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_ETA_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-start", - entity_registry_enabled_default=True, - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("eta", {}).get("start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_ETA_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-end", - entity_registry_enabled_default=True, - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("eta", {}).get("end")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_SLOT_START, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-start", - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("slot", {}).get("window_start")) - ), - ), - PicnicSensorEntityDescription( - key=SENSOR_NEXT_DELIVERY_SLOT_END, - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:calendar-end", - data_type="next_delivery_data", - value_fn=lambda next_delivery: dt_util.parse_datetime( - str(next_delivery.get("slot", {}).get("window_end")) - ), - ), -) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index e992945c510..74c37e9d5ce 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -1,11 +1,18 @@ """Definition of Picnic sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any, Literal, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -15,14 +22,205 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ( ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, - SENSOR_TYPES, - PicnicSensorEntityDescription, + SENSOR_CART_ITEMS_COUNT, + SENSOR_CART_TOTAL_PRICE, + SENSOR_LAST_ORDER_DELIVERY_TIME, + SENSOR_LAST_ORDER_MAX_ORDER_TIME, + SENSOR_LAST_ORDER_SLOT_END, + SENSOR_LAST_ORDER_SLOT_START, + SENSOR_LAST_ORDER_STATUS, + SENSOR_LAST_ORDER_TOTAL_PRICE, + SENSOR_NEXT_DELIVERY_ETA_END, + SENSOR_NEXT_DELIVERY_ETA_START, + SENSOR_NEXT_DELIVERY_SLOT_END, + SENSOR_NEXT_DELIVERY_SLOT_START, + SENSOR_SELECTED_SLOT_END, + SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + SENSOR_SELECTED_SLOT_START, +) + + +@dataclass +class PicnicRequiredKeysMixin: + """Mixin for required keys.""" + + data_type: Literal[ + "cart_data", "slot_data", "next_delivery_data", "last_order_data" + ] + value_fn: Callable[[Any], StateType | datetime] + + +@dataclass +class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): + """Describes Picnic sensor entity.""" + + entity_registry_enabled_default: bool = False + + +SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( + PicnicSensorEntityDescription( + key=SENSOR_CART_ITEMS_COUNT, + translation_key=SENSOR_CART_ITEMS_COUNT, + icon="mdi:format-list-numbered", + data_type="cart_data", + value_fn=lambda cart: cart.get("total_count", 0), + ), + PicnicSensorEntityDescription( + key=SENSOR_CART_TOTAL_PRICE, + translation_key=SENSOR_CART_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="cart_data", + value_fn=lambda cart: cart.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_START, + translation_key=SENSOR_SELECTED_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_start"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_END, + translation_key=SENSOR_SELECTED_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_end"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + translation_key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("cut_off_time"))), + ), + PicnicSensorEntityDescription( + key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + translation_key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:currency-eur", + entity_registry_enabled_default=True, + data_type="slot_data", + value_fn=lambda slot: ( + slot["minimum_order_value"] / 100 + if slot.get("minimum_order_value") + else None + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_START, + translation_key=SENSOR_LAST_ORDER_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("window_start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_SLOT_END, + translation_key=SENSOR_LAST_ORDER_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("window_end")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_STATUS, + translation_key=SENSOR_LAST_ORDER_STATUS, + icon="mdi:list-status", + data_type="last_order_data", + value_fn=lambda last_order: last_order.get("status"), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, + translation_key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-alert-outline", + entity_registry_enabled_default=True, + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("slot", {}).get("cut_off_time")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_DELIVERY_TIME, + translation_key=SENSOR_LAST_ORDER_DELIVERY_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:timeline-clock", + entity_registry_enabled_default=True, + data_type="last_order_data", + value_fn=lambda last_order: dt_util.parse_datetime( + str(last_order.get("delivery_time", {}).get("start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_LAST_ORDER_TOTAL_PRICE, + translation_key=SENSOR_LAST_ORDER_TOTAL_PRICE, + native_unit_of_measurement=CURRENCY_EURO, + icon="mdi:cash-marker", + data_type="last_order_data", + value_fn=lambda last_order: last_order.get("total_price", 0) / 100, + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ETA_START, + translation_key=SENSOR_NEXT_DELIVERY_ETA_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-start", + entity_registry_enabled_default=True, + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("eta", {}).get("start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ETA_END, + translation_key=SENSOR_NEXT_DELIVERY_ETA_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-end", + entity_registry_enabled_default=True, + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("eta", {}).get("end")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_SLOT_START, + translation_key=SENSOR_NEXT_DELIVERY_SLOT_START, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-start", + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("slot", {}).get("window_start")) + ), + ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_SLOT_END, + translation_key=SENSOR_NEXT_DELIVERY_SLOT_END, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:calendar-end", + data_type="next_delivery_data", + value_fn=lambda next_delivery: dt_util.parse_datetime( + str(next_delivery.get("slot", {}).get("window_end")) + ), + ), ) @@ -44,6 +242,7 @@ async def async_setup_entry( class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" + _attr_has_entity_name = True _attr_attribution = ATTRIBUTION entity_description: PicnicSensorEntityDescription @@ -60,7 +259,6 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): self.entity_id = f"sensor.picnic_{description.key}" self._service_unique_id = config_entry.unique_id - self._attr_name = self._to_capitalized_name(description.key) self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" @property @@ -88,7 +286,3 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): model=self._service_unique_id, name=f"Picnic: {self.coordinator.data[ADDRESS]}", ) - - @staticmethod - def _to_capitalized_name(name: str) -> str: - return name.replace("_", " ").capitalize() diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index 9eb51b2fd2a..ff91b5259b2 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -19,5 +19,57 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "cart_items_count": { + "name": "Cart items count" + }, + "cart_total_price": { + "name": "Cart total price" + }, + "selected_slot_start": { + "name": "Start of selected slot" + }, + "selected_slot_end": { + "name": "End of selected slot" + }, + "selected_slot_max_order_time": { + "name": "Max order time of selected slot" + }, + "selected_slot_min_order_value": { + "name": "Minimum order value for selected slot" + }, + "last_order_slot_start": { + "name": "Start of last order's slot" + }, + "last_order_slot_end": { + "name": "End of last order's slot" + }, + "last_order_status": { + "name": "Status of last order" + }, + "last_order_max_order_time": { + "name": "Max order time of last slot" + }, + "last_order_delivery_time": { + "name": "Last order delivery time" + }, + "last_order_total_price": { + "name": "Total price of last order" + }, + "next_delivery_eta_start": { + "name": "Expected start of next delivery" + }, + "next_delivery_eta_end": { + "name": "Expected end of next delivery" + }, + "next_delivery_slot_start": { + "name": "Start of next delivery's slot" + }, + "next_delivery_slot_end": { + "name": "End of next delivery's slot" + } + } } } diff --git a/homeassistant/components/pjlink/const.py b/homeassistant/components/pjlink/const.py new file mode 100644 index 00000000000..95e29e5bf23 --- /dev/null +++ b/homeassistant/components/pjlink/const.py @@ -0,0 +1,8 @@ +"""Constants for the PJLink integration.""" + +CONF_ENCODING = "encoding" + +DEFAULT_PORT = 4352 +DEFAULT_ENCODING = "utf-8" + +DOMAIN = "pjlink" diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 3fb5facac5e..4bbf1225a92 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -19,11 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_ENCODING = "encoding" - -DEFAULT_PORT = 4352 -DEFAULT_ENCODING = "utf-8" -DEFAULT_TIMEOUT = 10 +from .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN ERR_PROJECTOR_UNAVAILABLE = "projector unavailable" @@ -51,9 +47,9 @@ def setup_platform( encoding = config.get(CONF_ENCODING) password = config.get(CONF_PASSWORD) - if "pjlink" not in hass.data: - hass.data["pjlink"] = {} - hass_data = hass.data["pjlink"] + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + hass_data = hass.data[DOMAIN] device_label = f"{host}:{port}" if device_label in hass_data: diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 35273e6fce0..e385156c6d1 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.recorder import get_instance, history from homeassistant.const import ( - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONDUCTIVITY, CONF_SENSORS, @@ -29,48 +28,44 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from .const import ( + ATTR_DICT_OF_UNITS_OF_MEASUREMENT, + ATTR_MAX_BRIGHTNESS_HISTORY, + ATTR_PROBLEM, + ATTR_SENSORS, + CONF_CHECK_DAYS, + CONF_MAX_BRIGHTNESS, + CONF_MAX_CONDUCTIVITY, + CONF_MAX_MOISTURE, + CONF_MAX_TEMPERATURE, + CONF_MIN_BATTERY_LEVEL, + CONF_MIN_BRIGHTNESS, + CONF_MIN_CONDUCTIVITY, + CONF_MIN_MOISTURE, + CONF_MIN_TEMPERATURE, + DEFAULT_CHECK_DAYS, + DEFAULT_MAX_CONDUCTIVITY, + DEFAULT_MAX_MOISTURE, + DEFAULT_MIN_BATTERY_LEVEL, + DEFAULT_MIN_CONDUCTIVITY, + DEFAULT_MIN_MOISTURE, + DOMAIN, + PROBLEM_NONE, + READING_BATTERY, + READING_BRIGHTNESS, + READING_CONDUCTIVITY, + READING_MOISTURE, + READING_TEMPERATURE, +) + _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "plant" - -READING_BATTERY = "battery" -READING_TEMPERATURE = ATTR_TEMPERATURE -READING_MOISTURE = "moisture" -READING_CONDUCTIVITY = "conductivity" -READING_BRIGHTNESS = "brightness" - -ATTR_PROBLEM = "problem" -ATTR_SENSORS = "sensors" -PROBLEM_NONE = "none" -ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" - -# we're not returning only one value, we're returning a dict here. So we need -# to have a separate literal for it to avoid confusion. -ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" - -CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" -CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" -CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" -CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" -CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" -CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" -CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" -CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" -CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" -CONF_CHECK_DAYS = "check_days" - CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY CONF_SENSOR_MOISTURE = READING_MOISTURE CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS -DEFAULT_MIN_BATTERY_LEVEL = 20 -DEFAULT_MIN_MOISTURE = 20 -DEFAULT_MAX_MOISTURE = 60 -DEFAULT_MIN_CONDUCTIVITY = 500 -DEFAULT_MAX_CONDUCTIVITY = 3000 -DEFAULT_CHECK_DAYS = 3 SCHEMA_SENSORS = vol.Schema( { @@ -104,8 +99,6 @@ PLANT_SCHEMA = vol.Schema( } ) -DOMAIN = "plant" - CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: PLANT_SCHEMA}}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/plant/const.py b/homeassistant/components/plant/const.py new file mode 100644 index 00000000000..0368c55e152 --- /dev/null +++ b/homeassistant/components/plant/const.py @@ -0,0 +1,37 @@ +"""Const for Plant.""" +from typing import Final + +DOMAIN: Final = "plant" + +READING_MOISTURE = "moisture" +READING_BATTERY = "battery" +READING_TEMPERATURE = "temperature" +READING_CONDUCTIVITY = "conductivity" +READING_BRIGHTNESS = "brightness" + +CONF_MIN_BATTERY_LEVEL = f"min_{READING_BATTERY}" +CONF_MIN_TEMPERATURE = f"min_{READING_TEMPERATURE}" +CONF_MAX_TEMPERATURE = f"max_{READING_TEMPERATURE}" +CONF_MIN_MOISTURE = f"min_{READING_MOISTURE}" +CONF_MAX_MOISTURE = f"max_{READING_MOISTURE}" +CONF_MIN_CONDUCTIVITY = f"min_{READING_CONDUCTIVITY}" +CONF_MAX_CONDUCTIVITY = f"max_{READING_CONDUCTIVITY}" +CONF_MIN_BRIGHTNESS = f"min_{READING_BRIGHTNESS}" +CONF_MAX_BRIGHTNESS = f"max_{READING_BRIGHTNESS}" +CONF_CHECK_DAYS = "check_days" + +DEFAULT_MIN_BATTERY_LEVEL = 20 +DEFAULT_MIN_MOISTURE = 20 +DEFAULT_MAX_MOISTURE = 60 +DEFAULT_MIN_CONDUCTIVITY = 500 +DEFAULT_MAX_CONDUCTIVITY = 3000 +DEFAULT_CHECK_DAYS = 3 + +ATTR_PROBLEM = "problem" +ATTR_SENSORS = "sensors" +PROBLEM_NONE = "none" +ATTR_MAX_BRIGHTNESS_HISTORY = "max_brightness" + +# we're not returning only one value, we're returning a dict here. So we need +# to have a separate literal for it to avoid confusion. +ATTR_DICT_OF_UNITS_OF_MEASUREMENT = "unit_of_measurement_dict" diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 559f4440aef..59ae14b8ca9 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -35,6 +35,7 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, + INVALID_TOKEN_MESSAGE, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -153,6 +154,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: plexapi.exceptions.BadRequest, plexapi.exceptions.NotFound, ) as error: + if INVALID_TOKEN_MESSAGE in str(error): + raise ConfigEntryAuthFailed( + "Token not accepted, please reauthenticate Plex server" + f" '{entry.data[CONF_SERVER]}'" + ) from error _LOGGER.error( "Login to %s failed, verify token and SSL settings: [%s]", entry.data[CONF_SERVER], diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 3f761c9748a..7936cb6e6c3 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -57,3 +57,5 @@ SERVICE_REFRESH_LIBRARY = "refresh_library" SERVICE_SCAN_CLIENTS = "scan_for_clients" PLEX_URI_SCHEME = "plex://" + +INVALID_TOKEN_MESSAGE = "Invalid token" diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 89349e1f0b5..bfae7772b93 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -1,23 +1,84 @@ """Plugwise platform for Home Assistant Core.""" +from __future__ import annotations + +from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .gateway import async_setup_entry_gw, async_unload_entry_gw +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import PlugwiseDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" - if entry.data.get(CONF_HOST): - return await async_setup_entry_gw(hass, entry) - # PLACEHOLDER USB entry setup - return False + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) + + coordinator = PlugwiseDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + migrate_sensor_entities(hass, coordinator) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, + manufacturer="Plugwise", + model=coordinator.api.smile_model, + name=coordinator.api.smile_name, + sw_version=coordinator.api.smile_version[0], + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Plugwise components.""" - if entry.data.get(CONF_HOST): - return await async_unload_entry_gw(hass, entry) - # PLACEHOLDER USB entry setup - return False + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +@callback +def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate Plugwise entity entries. + + - Migrates unique ID from old relay switches to the new unique ID + """ + if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"): + return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")} + + # No migration needed + return None + + +def migrate_sensor_entities( + hass: HomeAssistant, + coordinator: PlugwiseDataUpdateCoordinator, +) -> None: + """Migrate Sensors if needed.""" + ent_reg = er.async_get(hass) + + # Migrating opentherm_outdoor_temperature + # to opentherm_outdoor_air_temperature sensor + for device_id, device in coordinator.data.devices.items(): + if device.get("dev_class") != "heater_central": + continue + + old_unique_id = f"{device_id}-outdoor_temperature" + if entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, old_unique_id + ): + new_unique_id = f"{device_id}-outdoor_air_temperature" + LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index d36ae1ba047..89c1b6eab52 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from plugwise import Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -11,7 +12,6 @@ from plugwise.exceptions import ( ResponseError, UnsupportedDeviceError, ) -from plugwise.smile import Smile import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index dd13e0e5092..34bb5c926ae 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -21,7 +21,7 @@ SMILE: Final = "smile" STRETCH: Final = "stretch" STRETCH_USERNAME: Final = "stretch" -PLATFORMS_GATEWAY: Final[list[str]] = [ +PLATFORMS: Final[list[str]] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.NUMBER, diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py deleted file mode 100644 index 282fd163e8a..00000000000 --- a/homeassistant/components/plugwise/gateway.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Plugwise platform for Home Assistant Core.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .const import DOMAIN, LOGGER, PLATFORMS_GATEWAY, Platform -from .coordinator import PlugwiseDataUpdateCoordinator - - -async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Plugwise Smiles from a config entry.""" - await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - - coordinator = PlugwiseDataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - migrate_sensor_entities(hass, coordinator) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, - manufacturer="Plugwise", - model=coordinator.api.smile_model, - name=coordinator.api.smile_name, - sw_version=coordinator.api.smile_version[0], - ) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS_GATEWAY) - - return True - - -async def async_unload_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_GATEWAY - ): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -@callback -def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: - """Migrate Plugwise entity entries. - - - Migrates unique ID from old relay switches to the new unique ID - """ - if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"): - return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")} - - # No migration needed - return None - - -def migrate_sensor_entities( - hass: HomeAssistant, - coordinator: PlugwiseDataUpdateCoordinator, -) -> None: - """Migrate Sensors if needed.""" - ent_reg = er.async_get(hass) - - # Migrating opentherm_outdoor_temperature - # to opentherm_outdoor_air_temperature sensor - for device_id, device in coordinator.data.devices.items(): - if device.get("dev_class") != "heater_central": - continue - - old_unique_id = f"{device_id}-outdoor_temperature" - if entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, old_unique_id - ): - new_unique_id = f"{device_id}-outdoor_air_temperature" - LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_id, - old_unique_id, - new_unique_id, - ) - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index f012e52b266..4fdcd0a8bdd 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,12 +1,12 @@ { "domain": "plugwise", "name": "Plugwise", - "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], + "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plugwise", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.27.5"], + "requirements": ["plugwise==0.31.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c060..c541e2cc0f2 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ class PocketCastsSensor(SensorEntity): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index db8b212cc5e..6306d52838e 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -10,7 +10,7 @@ "password": "[%key:common::config_flow::data::password%]" } }, - "reauth_confim": { + "reauth_confirm": { "title": "Reauthenticate the Powerwall", "description": "[%key:component::powerwall::config::step::user::description%]", "data": { diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index cfcb07773f5..b05a5f245ff 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -72,7 +72,7 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): """Update alarm status.""" try: - self._installation = await Installation.retrieve(self._auth) + self._installation = await Installation.retrieve(self._auth, self.contract) except ConnectionError as err: _LOGGER.error(err) self._attr_available = False diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 848b763903a..9041a6526fb 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -34,7 +34,9 @@ async def async_setup_entry( "async_request_image", ) - _installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + _installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data["contract"] + ) async_add_entities( [ diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ee2fa795f2d..ea975529b01 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -11,9 +11,9 @@ from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_COUNTRY, DOMAIN +from .const import CONF_CONTRACT, CONF_COUNTRY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,27 +31,22 @@ async def validate_input(hass: core.HomeAssistant, data): session = aiohttp_client.async_get_clientsession(hass) auth = Auth(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY]) try: - install = await Installation.retrieve(auth) + contracts = await Installation.list(auth) + return auth, contracts except ConnectionRefusedError: raise InvalidAuth from ConnectionRefusedError except ConnectionError: raise CannotConnect from ConnectionError - # Info to store in the config entry. - return { - "title": f"Contract {install.contract}", - "contract": install.contract, - "username": data[CONF_USERNAME], - "password": data[CONF_PASSWORD], - "country": data[CONF_COUNTRY], - } - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Prosegur Alarm.""" VERSION = 1 entry: ConfigEntry + auth: Auth + user_input: dict + contracts: list[dict[str, str]] async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -59,7 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: - info = await validate_input(self.hass, user_input) + self.auth, self.contracts = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -68,16 +63,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: - await self.async_set_unique_id(info["contract"]) - self._abort_if_unique_id_configured() - - user_input["contract"] = info["contract"] - return self.async_create_entry(title=info["title"], data=user_input) + self.user_input = user_input + return await self.async_step_choose_contract() return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_choose_contract( + self, user_input: Any | None = None + ) -> FlowResult: + """Let user decide which contract is being setup.""" + + if user_input: + await self.async_set_unique_id(user_input[CONF_CONTRACT]) + self._abort_if_unique_id_configured() + + self.user_input[CONF_CONTRACT] = user_input[CONF_CONTRACT] + + return self.async_create_entry( + title=f"Contract {user_input[CONF_CONTRACT]}", data=self.user_input + ) + + contract_options = [ + selector.SelectOptionDict(value=c["contractId"], label=c["description"]) + for c in self.contracts + ] + + return self.async_show_form( + step_id="choose_contract", + data_schema=vol.Schema( + { + vol.Required(CONF_CONTRACT): selector.SelectSelector( + selector.SelectSelectorConfig(options=contract_options) + ), + } + ), + ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Prosegur.""" self.entry = cast( @@ -93,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: try: user_input[CONF_COUNTRY] = self.entry.data[CONF_COUNTRY] - await validate_input(self.hass, user_input) + self.auth, self.contracts = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index 3f5b8691970..ea823e76062 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,5 +3,6 @@ DOMAIN = "prosegur" CONF_COUNTRY = "country" +CONF_CONTRACT = "contract" SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py index d2445698348..59b51f5b5d1 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_CONTRACT, DOMAIN TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} @@ -19,7 +19,9 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + installation = await Installation.retrieve( + hass.data[DOMAIN][entry.entry_id], entry.data[CONF_CONTRACT] + ) activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index d5081a82dbf..adf5e985fe9 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.8"] + "requirements": ["pyprosegur==0.0.9"] } diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index bf0beb4e766..a6c7fcc4a76 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -8,6 +8,11 @@ "country": "Country" } }, + "choose_contract": { + "data": { + "contract": "Contract" + } + }, "reauth_confirm": { "data": { "description": "Re-authenticate with Prosegur account.", diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2764f22b080..1c22ca50c23 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -22,10 +22,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, @@ -137,7 +134,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(build_client) - coordinators: dict[str, dict[str, dict[int, DataUpdateCoordinator]]] = {} + coordinators: dict[ + str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]] + ] = {} hass.data[DOMAIN][COORDINATORS] = coordinators # Create a coordinator for each vm/container @@ -252,54 +251,6 @@ def call_api_container_vm( return status -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int | None = None, - ) -> None: - """Initialize the Proxmox entity.""" - super().__init__(coordinator) - - self.coordinator = coordinator - self._unique_id = unique_id - self._name = name - self._host_name = host_name - self._icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id - - self._state = None - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available - - class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 828c8191148..ea02e547e98 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import COORDINATORS, DOMAIN, PROXMOX_CLIENTS, ProxmoxEntity +from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS +from .entity import ProxmoxEntity async def async_setup_platform( diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py new file mode 100644 index 00000000000..5dfd264df2d --- /dev/null +++ b/homeassistant/components/proxmoxve/entity.py @@ -0,0 +1,39 @@ +"""Proxmox parent entity class.""" + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class ProxmoxEntity(CoordinatorEntity): + """Represents any entity created for the Proxmox VE platform.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, + ) -> None: + """Initialize the Proxmox entity.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._attr_unique_id = unique_id + self._attr_name = name + self._host_name = host_name + self._attr_icon = icon + self._available = True + self._node_name = node_name + self._vm_id = vm_id + + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._available diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index ae977b16a45..7ebaa6e53dd 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 6b0e6189f41..cef2bdf2f6e 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -12,6 +12,7 @@ from pyprusalink import InvalidAuth, PrusaLink import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -24,8 +25,8 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("api_key"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): str, } ) @@ -35,7 +36,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data["host"], data["api_key"]) + api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) try: async with async_timeout.timeout(5): @@ -51,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, except AwesomeVersionException as err: raise NotSupported from err - return {"title": version["hostname"]} + return {"title": version["hostname"] or version["text"]} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -68,13 +69,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - host = user_input["host"].rstrip("/") + host = user_input[CONF_HOST].rstrip("/") if not host.startswith(("http://", "https://")): host = f"http://{host}" data = { - "host": host, - "api_key": user_input["api_key"], + CONF_HOST: host, + CONF_API_KEY: user_input[CONF_API_KEY], } errors = {} diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index bcfadb29166..4f93fd3407e 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -81,6 +81,45 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]), entity_registry_enabled_default=False, ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.temp-bed.target", + translation_key="heatbed_target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.temp-nozzle.target", + translation_key="nozzle_target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.z-height", + translation_key="z_height", + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["telemetry"]["z-height"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.print-speed", + translation_key="print_speed", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]), + ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="printer.telemetry.material", + translation_key="material", + icon="mdi:palette-swatch-variant", + value_fn=lambda data: cast(str, data["telemetry"]["material"]), + ), ), "job": ( PrusaLinkSensorEntityDescription[JobInfo]( diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 34611e4fffb..53f5f0153fe 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -29,20 +29,35 @@ "heatbed_temperature": { "name": "Heatbed temperature" }, + "heatbed_target_temperature": { + "name": "Heatbed target temperature" + }, "nozzle_temperature": { "name": "Nozzle temperature" }, + "nozzle_target_temperature": { + "name": "Nozzle target temperature" + }, "progress": { "name": "Progress" }, "filename": { "name": "Filename" }, + "material": { + "name": "Material" + }, "print_start": { "name": "Print start" }, "print_finish": { "name": "Print finish" + }, + "print_speed": { + "name": "Print speed" + }, + "z_height": { + "name": "Z-Height" } }, "button": { diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index b44862c527b..9518af77dbc 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -7,7 +7,7 @@ "mode": { "data": { "mode": "Config Mode", - "ip_address": "[%key:common::config_flow::data::ip%] (Leave empty if using Auto Discovery)." + "ip_address": "IP address (Leave empty if using Auto Discovery)." }, "data_description": { "ip_address": "Leave blank if selecting auto-discovery." @@ -28,8 +28,8 @@ "error": { "credential_timeout": "Credential service timed out. Press submit to restart.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "login_failed": "Failed to pair to PlayStation 4. Verify [%key:common::config_flow::data::pin%] is correct.", - "no_ipaddress": "Enter the [%key:common::config_flow::data::ip%] of the PlayStation 4 you would like to configure." + "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", + "no_ipaddress": "Enter the IP address of the PlayStation 4 you would like to configure." }, "abort": { "credential_error": "Error fetching credentials.", diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a9..5154ae155ec 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 00000000000..54c47c53895 --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 5f9ad42f7fc..0a79c67f400 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -1,3 +1,7 @@ """Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 00000000000..7f7833e912a --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 47090ab8b91..c56bb8102b8 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,6 +2,7 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cafb8d8b21e..6b758daab0a 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,12 +24,12 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +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 .const import DEFAULT_NAME +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,31 +70,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the qBittorrent sensors.""" + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + 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", + ) - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name) for description in SENSOR_TYPES + QBittorrentSensor(description, client, config_entry) + for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 00000000000..24d1885a917 --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 86bdf6c2dc7..787255187cc 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["pillow==9.4.0", "pyzbar==0.1.7"] + "requirements": ["pillow==9.5.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab2..d4db30fd61e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Random Sensor" DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ class RandomSensor(SensorEntity): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/rapt_ble/__init__.py b/homeassistant/components/rapt_ble/__init__.py new file mode 100644 index 00000000000..1b3d65ee2a4 --- /dev/null +++ b/homeassistant/components/rapt_ble/__init__.py @@ -0,0 +1,49 @@ +"""The rapt_ble integration.""" +from __future__ import annotations + +import logging + +from rapt_ble import RAPTPillBluetoothDeviceData + +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up RAPT BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = RAPTPillBluetoothDeviceData() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + coordinator.async_start() + ) # only start after all platforms have had a chance to subscribe + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rapt_ble/config_flow.py b/homeassistant/components/rapt_ble/config_flow.py new file mode 100644 index 00000000000..9323ed4eb76 --- /dev/null +++ b/homeassistant/components/rapt_ble/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for rapt_ble.""" +from __future__ import annotations + +from typing import Any + +from rapt_ble import RAPTPillBluetoothDeviceData as DeviceData +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for rapt_ble.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_device: DeviceData | None = None + self._discovered_devices: dict[str, str] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = DeviceData() + if not device.supported(discovery_info): + return self.async_abort(reason="not_supported") + self._discovery_info = discovery_info + self._discovered_device = device + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + assert self._discovery_info is not None + discovery_info = self._discovery_info + title = device.title or device.get_device_name() or discovery_info.name + if user_input is not None: + return self.async_create_entry(title=title, data={}) + + self._set_confirm_only() + placeholders = {"name": title} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = DeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/rapt_ble/const.py b/homeassistant/components/rapt_ble/const.py new file mode 100644 index 00000000000..21993a0183a --- /dev/null +++ b/homeassistant/components/rapt_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for the rapt_ble integration.""" + +DOMAIN = "rapt_ble" diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json new file mode 100644 index 00000000000..c144251960b --- /dev/null +++ b/homeassistant/components/rapt_ble/manifest.json @@ -0,0 +1,20 @@ +{ + "domain": "rapt_ble", + "name": "RAPT Bluetooth", + "bluetooth": [ + { + "manufacturer_id": 16722, + "manufacturer_data_start": [80, 84] + }, + { + "manufacturer_id": 17739, + "manufacturer_data_start": [71] + } + ], + "codeowners": ["@sairon"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/rapt_ble", + "iot_class": "local_push", + "requirements": ["rapt-ble==0.1.0"] +} diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py new file mode 100644 index 00000000000..9967a36faee --- /dev/null +++ b/homeassistant/components/rapt_ble/sensor.py @@ -0,0 +1,125 @@ +"""Support for RAPT Pill hydrometers.""" +from __future__ import annotations + +from rapt_ble import DeviceClass, DeviceKey, SensorUpdate, Units + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +from .const import DOMAIN + +SENSOR_DESCRIPTIONS = { + (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( + key=f"{DeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.SPECIFIC_GRAVITY, Units.SPECIFIC_GRAVITY): SensorEntityDescription( + key=f"{DeviceClass.SPECIFIC_GRAVITY}_{Units.SPECIFIC_GRAVITY}", + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( + key=f"{DeviceClass.BATTERY}_{Units.PERCENTAGE}", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ( + DeviceClass.SIGNAL_STRENGTH, + Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ): SensorEntityDescription( + key=f"{DeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +def _device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class and description.native_unit_of_measurement + }, + entity_data={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the RAPT Pill BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + RAPTPillBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class RAPTPillBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + SensorEntity, +): + """Representation of a RAPT Pill BLE sensor.""" + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json new file mode 100644 index 00000000000..7111626cca1 --- /dev/null +++ b/homeassistant/components/rapt_ble/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py index e32cbd4df7f..3cc2e74f95b 100644 --- a/homeassistant/components/recorder/auto_repairs/events/schema.py +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -8,6 +8,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -17,9 +18,12 @@ if TYPE_CHECKING: def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" - return validate_table_schema_supports_utf8( + schema_errors = validate_table_schema_supports_utf8( instance, EventData, (EventData.shared_data,) ) | validate_db_schema_precision(instance, Events) + for table in (Events, EventData): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) + return schema_errors def correct_db_schema( @@ -27,5 +31,6 @@ def correct_db_schema( schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - correct_db_schema_utf8(instance, EventData, schema_errors) + for table in (Events, EventData): + correct_db_schema_utf8(instance, table, schema_errors) correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index ec05eafd140..aa036f33999 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping import logging from typing import TYPE_CHECKING +from sqlalchemy import MetaData from sqlalchemy.exc import OperationalError from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -60,6 +61,60 @@ def validate_table_schema_supports_utf8( return schema_errors +def validate_table_schema_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Verify the table has the correct collation.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_has_correct_collation( + instance, table_object + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Ensure the table has the correct collation to avoid union errors with mixed collations.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + table = table_object.__tablename__ + metadata_obj = MetaData() + connection = session.connection() + metadata_obj.reflect(bind=connection) + dialect_kwargs = metadata_obj.tables[table].dialect_kwargs + # Check if the table has a collation set, if its not set than its + # using the server default collation for the database + + collate = ( + dialect_kwargs.get("mysql_collate") + or dialect_kwargs.get( + "mariadb_collate" + ) # pylint: disable-next=protected-access + or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] + ) + if collate and collate != "utf8mb4_unicode_ci": + _LOGGER.debug( + "Database %s collation is not utf8mb4_unicode_ci", + table, + ) + schema_errors.add(f"{table}.utf8mb4_unicode_ci") + return schema_errors + + def _validate_table_schema_supports_utf8( instance: Recorder, table_object: type[DeclarativeBase], @@ -184,7 +239,10 @@ def correct_db_schema_utf8( ) -> None: """Correct utf8 issues detected by validate_db_schema.""" table_name = table_object.__tablename__ - if f"{table_name}.4-byte UTF-8" in schema_errors: + if ( + f"{table_name}.4-byte UTF-8" in schema_errors + or f"{table_name}.utf8mb4_unicode_ci" in schema_errors + ): from ..migration import ( # pylint: disable=import-outside-toplevel _correct_table_character_set_and_collation, ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py index 258e15cbb52..3c0daef452d 100644 --- a/homeassistant/components/recorder/auto_repairs/states/schema.py +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -8,6 +8,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -26,6 +27,8 @@ def validate_db_schema(instance: Recorder) -> set[str]: for table, columns in TABLE_UTF8_COLUMNS.items(): schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) schema_errors |= validate_db_schema_precision(instance, States) + for table in (States, StateAttributes): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) return schema_errors diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index 9b4687cb72d..607935bd6ff 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -9,6 +9,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -26,6 +27,7 @@ def validate_db_schema(instance: Recorder) -> set[str]: ) for table in (Statistics, StatisticsShortTerm): schema_errors |= validate_db_schema_precision(instance, table) + schema_errors |= validate_table_schema_has_correct_collation(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -41,3 +43,4 @@ def correct_db_schema( correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) for table in (Statistics, StatisticsShortTerm): correct_db_schema_precision(instance, table, schema_errors) + correct_db_schema_utf8(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index fbec19a2d1e..ec5c5c984b5 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -19,7 +19,9 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED = "recorder_hourly_statistics_generat CONF_DB_INTEGRITY_CHECK = "db_integrity_check" -MAX_QUEUE_BACKLOG = 65000 +MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 +ESTIMATED_QUEUE_ITEM_SIZE = 10240 +QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY = 0.65 # The maximum number of rows (events) we purge in one delete statement diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8969b7a27e3..43915c0187b 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,11 +11,13 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar +from typing import Any, TypeVar, cast import async_timeout +import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine +from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm.session import Session @@ -23,8 +25,8 @@ from sqlalchemy.orm.session import Session from homeassistant.components import persistent_notification from homeassistant.const import ( ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, - EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, MATCH_ALL, ) @@ -44,14 +46,16 @@ from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, DB_WORKER_PREFIX, DOMAIN, + ESTIMATED_QUEUE_ITEM_SIZE, EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, - MAX_QUEUE_BACKLOG, + MAX_QUEUE_BACKLOG_MIN_VALUE, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, + QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY, SQLITE_URL_PREFIX, STATES_META_SCHEMA_VERSION, STATISTICS_ROWS_SCHEMA_VERSION, @@ -147,7 +151,7 @@ WAIT_TASK = WaitTask() ADJUST_LRU_SIZE_TASK = AdjustLRUSizeTask() DB_LOCK_TIMEOUT = 30 -DB_LOCK_QUEUE_CHECK_TIMEOUT = 1 +DB_LOCK_QUEUE_CHECK_TIMEOUT = 10 # check every 10 seconds INVALIDATED_ERR = "Database connection invalidated" @@ -200,6 +204,8 @@ class Recorder(threading.Thread): self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Engine | None = None + self.max_backlog: int = MAX_QUEUE_BACKLOG_MIN_VALUE + self._psutil: ha_psutil.PsutilWrapper | None = None # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -342,7 +348,7 @@ class Recorder(threading.Thread): """ size = self.backlog _LOGGER.debug("Recorder queue size is: %s", size) - if size <= MAX_QUEUE_BACKLOG: + if not self._reached_max_backlog_percentage(100): return _LOGGER.error( ( @@ -351,10 +357,33 @@ class Recorder(threading.Thread): "is corrupt due to a disk problem; The recorder will stop " "recording events to avoid running out of memory" ), - MAX_QUEUE_BACKLOG, + self.backlog, ) self._async_stop_queue_watcher_and_event_listener() + def _available_memory(self) -> int: + """Return the available memory in bytes.""" + if not self._psutil: + self._psutil = ha_psutil.PsutilWrapper() + return cast(int, self._psutil.psutil.virtual_memory().available) + + def _reached_max_backlog_percentage(self, percentage: int) -> bool: + """Check if the system has reached the max queue backlog and return the maximum if it has.""" + percentage_modifier = percentage / 100 + current_backlog = self.backlog + # First check the minimum value since its cheap + if current_backlog < (MAX_QUEUE_BACKLOG_MIN_VALUE * percentage_modifier): + return False + # If they have more RAM available, keep filling the backlog + # since we do not want to stop recording events or give the + # user a bad backup when they have plenty of RAM available. + max_queue_backlog = int( + QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY + * (self._available_memory() / ESTIMATED_QUEUE_ITEM_SIZE) + ) + self.max_backlog = max(max_queue_backlog, MAX_QUEUE_BACKLOG_MIN_VALUE) + return current_backlog >= (max_queue_backlog * percentage_modifier) + @callback def _async_stop_queue_watcher_and_event_listener(self) -> None: """Stop watching the queue and listening for events.""" @@ -403,9 +432,8 @@ class Recorder(threading.Thread): # Unknown what it is. return True - @callback - def _async_empty_queue(self, event: Event) -> None: - """Empty the queue if its still present at final write.""" + async def _async_close(self, event: Event) -> None: + """Empty the queue if its still present at close.""" # If the queue is full of events to be processed because # the database is so broken that every event results in a retry @@ -420,9 +448,10 @@ class Recorder(threading.Thread): except queue.Empty: break self.queue_task(StopTask()) + await self.hass.async_add_executor_job(self.join) async def _async_shutdown(self, event: Event) -> None: - """Shut down the Recorder.""" + """Shut down the Recorder at final write.""" if not self._hass_started.done(): self._hass_started.set_result(SHUTDOWN_TASK) self.queue_task(StopTask()) @@ -438,15 +467,22 @@ class Recorder(threading.Thread): def async_register(self) -> None: """Post connection initialize.""" bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_empty_queue) - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, self._async_close) + bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_shutdown) async_at_started(self.hass, self._async_hass_started) @callback - def async_connection_failed(self) -> None: - """Connect failed tasks.""" - self.async_db_connected.set_result(False) - self.async_db_ready.set_result(False) + def _async_startup_failed(self) -> None: + """Report startup failure.""" + # If a live migration failed, we were able to connect (async_db_connected + # marked True), the database was marked ready (async_db_ready marked + # True), the data in the queue cannot be written to the database because + # the schema not in the correct format so we must stop listeners and report + # failure. + if not self.async_db_connected.done(): + self.async_db_connected.set_result(False) + if not self.async_db_ready.done(): + self.async_db_ready.set_result(False) persistent_notification.async_create( self.hass, "The recorder could not start, check [the logs](/config/logs)", @@ -644,19 +680,26 @@ class Recorder(threading.Thread): return SHUTDOWN_TASK def run(self) -> None: + """Run the recorder thread.""" + try: + self._run() + finally: + # Ensure shutdown happens cleanly if + # anything goes wrong in the run loop + self._shutdown() + + def _run(self) -> None: """Start processing events to save.""" self.thread_id = threading.get_ident() setup_result = self._setup_recorder() if not setup_result: # Give up if we could not connect - self.hass.add_job(self.async_connection_failed) return schema_status = migration.validate_db_schema(self.hass, self, self.get_session) if schema_status is None: # Give up if we could not validate the schema - self.hass.add_job(self.async_connection_failed) return self.schema_version = schema_status.current_version @@ -683,7 +726,6 @@ class Recorder(threading.Thread): self.migration_in_progress = False # Make sure we cleanly close the run if # we restart before startup finishes - self._shutdown() return if not schema_status.valid: @@ -691,8 +733,8 @@ class Recorder(threading.Thread): self.schema_version = SCHEMA_VERSION if not self._event_listener: # If the schema migration takes so long that the end - # queue watcher safety kicks in because MAX_QUEUE_BACKLOG - # is reached, we need to reinitialize the listener. + # queue watcher safety kicks in because _reached_max_backlog + # was True, we need to reinitialize the listener. self.hass.add_job(self.async_initialize) else: persistent_notification.create( @@ -701,8 +743,6 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self.hass.add_job(self.async_set_db_ready) - self._shutdown() return if not database_was_ready: @@ -714,7 +754,6 @@ class Recorder(threading.Thread): self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) self._run_event_loop() - self._shutdown() def _activate_and_set_db_ready(self) -> None: """Activate the table managers or schedule migrations and mark the db as ready.""" @@ -935,12 +974,14 @@ class Recorder(threading.Thread): # Notify that lock is being held, wait until database can be used again. self.hass.add_job(_async_set_database_locked, task) while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT): - if self.backlog > MAX_QUEUE_BACKLOG * 0.9: + if self._reached_max_backlog_percentage(90): _LOGGER.warning( - "Database queue backlog reached more than 90% of maximum queue " + "Database queue backlog reached more than %s (%s events) of maximum queue " "length while waiting for backup to finish; recorder will now " "resume writing to database. The backup cannot be trusted and " - "must be restarted" + "must be restarted", + "90%", + self.backlog, ) task.queue_overflow = True break @@ -970,7 +1011,7 @@ class Recorder(threading.Thread): event_type_manager = self.event_type_manager if pending_event_types := event_type_manager.get_pending(event.event_type): dbevent.event_type_rel = pending_event_types - elif event_type_id := event_type_manager.get(event.event_type, session): + elif event_type_id := event_type_manager.get(event.event_type, session, True): dbevent.event_type_id = event_type_id else: event_types = EventTypes(event_type=event.event_type) @@ -1293,25 +1334,25 @@ class Recorder(threading.Thread): return success + def _setup_recorder_connection( + self, dbapi_connection: DBAPIConnection, connection_record: Any + ) -> None: + """Dbapi specific connection settings.""" + assert self.engine is not None + if database_engine := setup_connection_for_dialect( + self, + self.engine.dialect.name, + dbapi_connection, + not self._completed_first_database_setup, + ): + self.database_engine = database_engine + self._completed_first_database_setup = True + def _setup_connection(self) -> None: """Ensure database is ready to fly.""" kwargs: dict[str, Any] = {} self._completed_first_database_setup = False - def setup_recorder_connection( - dbapi_connection: Any, connection_record: Any - ) -> None: - """Dbapi specific connection settings.""" - assert self.engine is not None - if database_engine := setup_connection_for_dialect( - self, - self.engine.dialect.name, - dbapi_connection, - not self._completed_first_database_setup, - ): - self.database_engine = database_engine - self._completed_first_database_setup = True - if self.db_url == SQLITE_URL_PREFIX or ":memory:" in self.db_url: kwargs["connect_args"] = {"check_same_thread": False} kwargs["poolclass"] = MutexPool @@ -1346,7 +1387,7 @@ class Recorder(threading.Thread): self.engine = create_engine(self.db_url, **kwargs, future=True) self._dialect_name = try_parse_enum(SupportedDialect, self.engine.dialect.name) - sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection) + sqlalchemy_event.listen(self.engine, "connect", self._setup_recorder_connection) Base.metadata.create_all(self.engine) self._get_session = scoped_session(sessionmaker(bind=self.engine, future=True)) @@ -1354,9 +1395,9 @@ class Recorder(threading.Thread): def _close_connection(self) -> None: """Close the connection.""" - assert self.engine is not None - self.engine.dispose() - self.engine = None + if self.engine: + self.engine.dispose() + self.engine = None self._get_session = None def _setup_run(self) -> None: @@ -1388,9 +1429,19 @@ class Recorder(threading.Thread): def _shutdown(self) -> None: """Save end time for current run.""" _LOGGER.debug("Shutting down recorder") - self.hass.add_job(self._async_stop_listeners) - self._stop_executor() + if not self.schema_version or self.schema_version != SCHEMA_VERSION: + # If the schema version is not set, we never had a working + # connection to the database or the schema never reached a + # good state. + # + # In either case, we want to mark startup as failed. + # + self.hass.add_job(self._async_startup_failed) + else: + self.hass.add_job(self._async_stop_listeners) + try: self._end_session() finally: + self._stop_executor() self._close_connection() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 617e56848d9..0743864aaf7 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -3,14 +3,14 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -from functools import lru_cache import logging import time from typing import Any, cast import ciso8601 -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( + CHAR, JSON, BigInteger, Boolean, @@ -25,17 +25,18 @@ from sqlalchemy import ( SmallInteger, String, Text, + case, type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship +from sqlalchemy.types import TypeDecorator from typing_extensions import Self from homeassistant.const import ( - MAX_LENGTH_EVENT_CONTEXT_ID, MAX_LENGTH_EVENT_EVENT_TYPE, - MAX_LENGTH_EVENT_ORIGIN, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) @@ -136,6 +137,27 @@ _DEFAULT_TABLE_ARGS = { } +class UnusedDateTime(DateTime): + """An unused column type that behaves like a datetime.""" + + +class Unused(CHAR): + """An unused column type that behaves like a string.""" + + +@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: + """Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite.""" + return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite) + + +@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call] +def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: + """Compile Unused as CHAR(1) on postgresql.""" + return "CHAR(1)" # Uses 1 byte + + class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" @@ -144,6 +166,19 @@ class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): return lambda value: None if value is None else ciso8601.parse_datetime(value) +class NativeLargeBinary(LargeBinary): + """A faster version of LargeBinary for engines that support python bytes natively.""" + + def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] + """No conversion needed for engines that support native bytes.""" + return None + + +# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 +# for sqlite and postgresql we use a bigint +UINT_32_TYPE = BigInteger().with_variant( + mysql.INTEGER(unsigned=True), "mysql", "mariadb" # type: ignore[no-untyped-call] +) JSON_VARIANT_CAST = Text().with_variant( postgresql.JSON(none_as_null=True), "postgresql" # type: ignore[no-untyped-call] ) @@ -161,7 +196,13 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +UNUSED_LEGACY_COLUMN = Unused(0) +UNUSED_LEGACY_DATETIME_COLUMN = UnusedDateTime(timezone=True) +UNUSED_LEGACY_INTEGER_COLUMN = SmallInteger() DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" +CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant( + NativeLargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), "mysql", "mariadb", "sqlite" +) TIMESTAMP_TYPE = DOUBLE_TYPE @@ -202,41 +243,21 @@ class Events(Base): ) __tablename__ = TABLE_EVENTS event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_EVENT_TYPE) - ) # no longer used - event_data: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") - ) - origin: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_ORIGIN) - ) # no longer used for new rows + event_type: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + event_data: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + origin: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) origin_idx: Mapped[int | None] = mapped_column(SmallInteger) - time_fired: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + time_fired: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) - context_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) + context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) data_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_data.data_id"), index=True ) - context_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_user_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_parent_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) - ) + context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) event_type_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_types.event_type_id") ) @@ -310,7 +331,7 @@ class EventData(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_DATA data_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - hash: Mapped[int | None] = mapped_column(BigInteger, index=True) + hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_data: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -344,10 +365,9 @@ class EventData(Base): return bytes_result @staticmethod - @lru_cache def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data_bytes)) + return fnv1a_32(shared_data_bytes) def to_native(self) -> dict[str, Any]: """Convert to an event data dictionary.""" @@ -397,21 +417,13 @@ class States(Base): ) __tablename__ = TABLE_STATES state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_STATE_ENTITY_ID) - ) # no longer used for new rows + entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) - attributes: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") - ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column(Integer) # no longer used for new rows - last_changed: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + attributes: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + event_id: Mapped[int | None] = mapped_column(UNUSED_LEGACY_INTEGER_COLUMN) + last_changed: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_changed_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) - last_updated: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE - ) # no longer used for new rows + last_updated: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_updated_ts: Mapped[float | None] = mapped_column( TIMESTAMP_TYPE, default=time.time, index=True ) @@ -421,29 +433,17 @@ class States(Base): attributes_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("state_attributes.attributes_id"), index=True ) - context_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True - ) - context_user_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) - context_parent_id: Mapped[str | None] = mapped_column( # no longer used - String(MAX_LENGTH_EVENT_CONTEXT_ID) - ) + context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) origin_idx: Mapped[int | None] = mapped_column( SmallInteger ) # 0 is local, 1 is remote old_state: Mapped[States | None] = relationship("States", remote_side=[state_id]) state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes") - context_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_user_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), - ) - context_parent_id_bin: Mapped[bytes | None] = mapped_column( - LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) - ) + context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) + context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE) metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("states_meta.metadata_id") ) @@ -544,7 +544,7 @@ class StateAttributes(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATE_ATTRIBUTES attributes_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - hash: Mapped[int | None] = mapped_column(BigInteger, index=True) + hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_attrs: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -593,10 +593,9 @@ class StateAttributes(Base): return bytes_result @staticmethod - @lru_cache(maxsize=2048) def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs_bytes)) + return fnv1a_32(shared_attrs_bytes) def to_native(self) -> dict[str, Any]: """Convert to a state attributes dictionary.""" @@ -634,20 +633,18 @@ class StatisticsBase: """Statistics base class.""" id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - created: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) # No longer used + created: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) created_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, default=time.time) metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), ) - start: Mapped[datetime | None] = mapped_column( - DATETIME_TYPE, index=True - ) # No longer used + start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE) min: Mapped[float | None] = mapped_column(DOUBLE_TYPE) max: Mapped[float | None] = mapped_column(DOUBLE_TYPE) - last_reset: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) + last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) last_reset_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE) state: Mapped[float | None] = mapped_column(DOUBLE_TYPE) sum: Mapped[float | None] = mapped_column(DOUBLE_TYPE) @@ -825,3 +822,11 @@ ENTITY_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["entity_id"] OLD_ENTITY_ID_IN_EVENT: ColumnElement = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] DEVICE_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["device_id"] OLD_STATE = aliased(States, name="old_state") + +SHARED_ATTR_OR_LEGACY_ATTRIBUTES = case( + (StateAttributes.shared_attrs.is_(None), States.attributes), + else_=StateAttributes.shared_attrs, +).label("attributes") +SHARED_DATA_OR_LEGACY_EVENT_DATA = case( + (EventData.shared_data.is_(None), Events.event_data), else_=EventData.shared_data +).label("event_data") diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py index 33717ca78cf..61a615a7979 100644 --- a/homeassistant/components/recorder/history/const.py +++ b/homeassistant/components/recorder/history/const.py @@ -13,7 +13,6 @@ SIGNIFICANT_DOMAINS = { } SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] IGNORE_DOMAINS = {"zone", "scene"} -IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] NEED_ATTRIBUTE_DOMAINS = { "climate", "humidifier", diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index c33825a767c..74b17d9daa7 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -5,7 +5,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby -import logging from operator import attrgetter import time from typing import Any, cast @@ -13,7 +12,6 @@ from typing import Any, cast from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -26,17 +24,19 @@ from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters from ..models import ( - LazyState, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, - row_to_compressed_state, ) -from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_schema_31 +from ..models.legacy import ( + LegacyLazyState, + LegacyLazyStatePreSchema31, + legacy_row_to_compressed_state, + legacy_row_to_compressed_state_pre_schema_31, +) from ..util import execute_stmt_lambda_element, session_scope from .common import _schema_version from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, @@ -44,9 +44,6 @@ from .const import ( STATE_KEY, ) -_LOGGER = logging.getLogger(__name__) - - _BASE_STATES = ( States.entity_id, States.state, @@ -229,24 +226,11 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~States.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_ids: list[str] | None, - filters: Filters | None, + entity_ids: list[str], significant_changes_only: bool, no_attributes: bool, ) -> StatementLambdaElement: @@ -255,8 +239,7 @@ def _significant_states_stmt( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( - entity_ids - and len(entity_ids) == 1 + len(entity_ids) == 1 and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): @@ -297,18 +280,7 @@ def _significant_states_stmt( ), ) ) - - if entity_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.entity_id.in_(entity_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] - ) + stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) if schema_version >= 31: start_time_ts = start_time.timestamp() @@ -356,25 +328,25 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") stmt = _significant_states_stmt( _schema_version(hass), start_time, end_time, entity_ids, - filters, significant_changes_only, no_attributes, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return _sorted_states_to_dict( hass, session, states, start_time, entity_ids, - filters, include_start_time_state, minimal_response, no_attributes, @@ -419,7 +391,7 @@ def _state_changed_during_period_stmt( schema_version: int, start_time: datetime, end_time: datetime | None, - entity_id: str | None, + entity_id: str, no_attributes: bool, descending: bool, limit: int | None, @@ -450,8 +422,7 @@ def _state_changed_during_period_stmt( stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) else: stmt += lambda q: q.filter(States.last_updated < end_time) - if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -484,9 +455,9 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None - + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), @@ -497,9 +468,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time - ) + states = execute_stmt_lambda_element(session, stmt, None, end_time) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -647,93 +616,17 @@ def _get_states_for_entities_stmt( return stmt -def _get_states_for_all_stmt( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, - no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - else: - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] - ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - return stmt - - def _get_rows_with_session( hass: HomeAssistant, session: Session, utc_point_in_time: datetime, - entity_ids: list[str] | None = None, + entity_ids: list[str], run: RecorderRuns | None = None, - filters: Filters | None = None, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" schema_version = _schema_version(hass) - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( @@ -750,15 +643,9 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - stmt = _get_states_for_entities_stmt( - schema_version, run.start, utc_point_in_time, entity_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - schema_version, run.start, utc_point_in_time, filters, no_attributes - ) - + stmt = _get_states_for_entities_stmt( + schema_version, run.start, utc_point_in_time, entity_ids, no_attributes + ) return execute_stmt_lambda_element(session, stmt) @@ -804,8 +691,7 @@ def _sorted_states_to_dict( session: Session, states: Iterable[Row], start_time: datetime, - entity_ids: list[str] | None, - filters: Filters | None = None, + entity_ids: list[str], include_start_time_state: bool = True, minimal_response: bool = False, no_attributes: bool = False, @@ -830,29 +716,28 @@ def _sorted_states_to_dict( ] if compressed_state_format: if schema_version >= 31: - state_class = row_to_compressed_state + state_class = legacy_row_to_compressed_state else: - state_class = row_to_compressed_state_pre_schema_31 + state_class = legacy_row_to_compressed_state_pre_schema_31 _process_timestamp = process_datetime_to_timestamp attr_time = COMPRESSED_STATE_LAST_UPDATED attr_state = COMPRESSED_STATE_STATE else: if schema_version >= 31: - state_class = LazyState + state_class = LegacyLazyState else: - state_class = LazyStatePreSchema31 + state_class = LegacyLazyStatePreSchema31 _process_timestamp = process_timestamp_to_utc_isoformat attr_time = LAST_CHANGED_KEY attr_state = STATE_KEY result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] + for ent_id in entity_ids: + result[ent_id] = [] # Get the states at the start time - timer_start = time.perf_counter() + time.perf_counter() initial_states: dict[str, Row] = {} if include_start_time_state: initial_states = { @@ -862,16 +747,11 @@ def _sorted_states_to_dict( session, start_time, entity_ids, - filters=filters, no_attributes=no_attributes, ) } - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug("getting %d first datapoints took %fs", len(result), elapsed) - - if entity_ids and len(entity_ids) == 1: + if len(entity_ids) == 1: states_iter: Iterable[tuple[str, Iterator[Row]]] = ( (entity_ids[0], iter(states)), ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 44950b8fe71..5322074c205 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -1,101 +1,110 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations -from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby from operator import itemgetter from typing import Any, cast -from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy import ( + CompoundSelect, + Integer, + Select, + Subquery, + and_, + func, + lambda_stmt, + literal, + select, + union_all, +) +from sqlalchemy.dialects import postgresql from sqlalchemy.engine.row import Row -from sqlalchemy.orm.properties import MappedColumn -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal -from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util from ... import recorder -from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..db_schema import SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, States from ..filters import Filters from ..models import ( LazyState, + datetime_to_timestamp_or_none, extract_metadata_ids, process_timestamp, row_to_compressed_state, ) from ..util import execute_stmt_lambda_element, session_scope from .const import ( - IGNORE_DOMAINS_ENTITY_ID_LIKE, LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS, - SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, STATE_KEY, ) -_BASE_STATES = ( - States.metadata_id, - States.state, - States.last_changed_ts, - States.last_updated_ts, -) -_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] - States.metadata_id, - States.state, - literal(value=None).label("last_changed_ts"), - States.last_updated_ts, -) -_QUERY_STATE_NO_ATTR = (*_BASE_STATES,) -_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (*_BASE_STATES_NO_LAST_CHANGED,) -_QUERY_STATES = ( - *_BASE_STATES, - # Remove States.attributes once all attributes are in StateAttributes.shared_attrs - States.attributes, - StateAttributes.shared_attrs, -) -_QUERY_STATES_NO_LAST_CHANGED = ( - *_BASE_STATES_NO_LAST_CHANGED, - # Remove States.attributes once all attributes are in StateAttributes.shared_attrs - States.attributes, - StateAttributes.shared_attrs, -) _FIELD_MAP = { - cast(MappedColumn, field).name: idx - for idx, field in enumerate(_QUERY_STATE_NO_ATTR) + "metadata_id": 0, + "state": 1, + "last_updated_ts": 2, } -def _lambda_stmt_and_join_attributes( - no_attributes: bool, include_last_changed: bool = True -) -> tuple[StatementLambdaElement, bool]: - """Return the lambda_stmt and if StateAttributes should be joined. +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") +) - Because these are lambda_stmt the values inside the lambdas need - to be explicitly written out to avoid caching the wrong values. - """ - # If no_attributes was requested we do the query - # without the attributes fields and do not join the - # state_attributes table - if no_attributes: - if include_last_changed: - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), - False, - ) - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), - False, - ) +def _stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement and if StateAttributes should be joined.""" + _select = select(States.metadata_id, States.state, States.last_updated_ts) if include_last_changed: - return lambda_stmt(lambda: select(*_QUERY_STATES)), True - return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + _select = _select.add_columns(States.last_changed_ts) + if not no_attributes: + _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) + return _select + + +def _stmt_and_join_attributes_for_start_state( + no_attributes: bool, include_last_changed: bool +) -> 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) + ) + if include_last_changed: + _select = _select.add_columns( + literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE) + ) + if not no_attributes: + _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) + return _select + + +def _select_from_subquery( + subquery: Subquery | CompoundSelect, no_attributes: bool, include_last_changed: bool +) -> Select: + """Return the statement to select from the union.""" + base_select = select( + subquery.c.metadata_id, + subquery.c.state, + subquery.c.last_updated_ts, + ) + if include_last_changed: + base_select = base_select.add_columns(subquery.c.last_changed_ts) + if no_attributes: + return base_select + return base_select.add_columns(subquery.c.attributes) def get_significant_states( @@ -127,92 +136,66 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: - """Add a filter to ignore domains we do not fetch history for.""" - return query.filter( - and_( - *[ - ~StatesMeta.entity_id.like(entity_domain) - for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE - ] - ) - ) - - def _significant_states_stmt( - start_time: datetime, - end_time: datetime | None, - metadata_ids: list[int] | None, + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], metadata_ids_in_significant_domains: list[int], - filters: Filters | None, significant_changes_only: bool, no_attributes: bool, -) -> StatementLambdaElement: + include_start_time_state: bool, + run_start_ts: float | None, +) -> Select | CompoundSelect: """Query the database for significant state changes.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=not significant_changes_only - ) - join_states_meta = False - if metadata_ids and significant_changes_only: + include_last_changed = not significant_changes_only + stmt = _stmt_and_join_attributes(no_attributes, include_last_changed) + if significant_changes_only: # Since we are filtering on entity_id (metadata_id) we can avoid # the join of the states_meta table since we already know which # metadata_ids are in the significant domains. - stmt += lambda q: q.filter( - States.metadata_id.in_(metadata_ids_in_significant_domains) - | (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ) - elif significant_changes_only: - # This is the case where we are not filtering on entity_id - # so we need to join the states_meta table to filter out - # the domains we do not care about. This query path was - # only used by the old history page to show all entities - # in the UI. The new history page filters on entity_id - # so this query path is not used anymore except for third - # party integrations that use the history API. - stmt += lambda q: q.filter( - or_( - *[ - StatesMeta.entity_id.like(entity_domain) - for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE - ], - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ), + if metadata_ids_in_significant_domains: + stmt = stmt.filter( + States.metadata_id.in_(metadata_ids_in_significant_domains) + | (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) ) - ) - join_states_meta = True - - if metadata_ids: - stmt += lambda q: q.filter( - # https://github.com/python/mypy/issues/2608 - States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] - ) - else: - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] - track_on=[filters], + else: + stmt = stmt.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) ) - join_states_meta = True - - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) - if end_time: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - if join_states_meta: - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.filter(States.metadata_id.in_(metadata_ids)).filter( + States.last_updated_ts > start_time_ts + ) + if end_time_ts: + stmt = stmt.filter(States.last_updated_ts < end_time_ts) + if not no_attributes: + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) - return stmt + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) + if not include_start_time_state or not run_start_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(), + no_attributes, + include_last_changed, + ), + _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), + ).subquery(), + no_attributes, + include_last_changed, + ) def get_significant_states_with_session( @@ -239,47 +222,62 @@ def get_significant_states_with_session( as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ - metadata_ids: list[int] | None = None + if filters is not None: + raise NotImplementedError("Filters are no longer supported") + if not entity_ids: + raise ValueError("entity_ids must be provided") entity_id_to_metadata_id: dict[str, int | None] | None = None metadata_ids_in_significant_domains: list[int] = [] - if entity_ids: - instance = recorder.get_instance(hass) - if not ( - entity_id_to_metadata_id := instance.states_meta_manager.get_many( - entity_ids, session, False - ) - ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): - return {} - if significant_changes_only: - metadata_ids_in_significant_domains = [ - metadata_id - for entity_id, metadata_id in entity_id_to_metadata_id.items() - if metadata_id is not None - and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS - ] - stmt = _significant_states_stmt( - start_time, - end_time, - metadata_ids, - metadata_ids_in_significant_domains, - filters, - significant_changes_only, - no_attributes, - ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time + instance = recorder.get_instance(hass) + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (possible_metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} + metadata_ids = possible_metadata_ids + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] + run_start_ts: float | None = None + if include_start_time_state and not ( + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + ): + include_start_time_state = False + start_time_ts = dt_util.utc_to_timestamp(start_time) + end_time_ts = datetime_to_timestamp_or_none(end_time) + single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None + stmt = lambda_stmt( + lambda: _significant_states_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + run_start_ts, + ), + track_on=[ + bool(single_metadata_id), + bool(metadata_ids_in_significant_domains), + bool(end_time_ts), + significant_changes_only, + no_attributes, + include_start_time_state, + ], ) return _sorted_states_to_dict( - hass, - session, - states, - start_time, + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + start_time_ts if include_start_time_state else None, entity_ids, entity_id_to_metadata_id, - filters, - include_start_time_state, minimal_response, - no_attributes, compressed_state_format, ) @@ -318,40 +316,60 @@ def get_full_significant_states_with_session( def _state_changed_during_period_stmt( - start_time: datetime, - end_time: datetime | None, - metadata_id: int | None, + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int, no_attributes: bool, - descending: bool, limit: int | None, -) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=False - ) - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter( - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) + include_start_time_state: bool, + run_start_ts: float | None, +) -> Select | CompoundSelect: + stmt = ( + _stmt_and_join_attributes(no_attributes, False) + .filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + & (States.last_updated_ts > start_time_ts) ) - & (States.last_updated_ts > start_time_ts) + .filter(States.metadata_id == single_metadata_id) ) - if end_time: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - if metadata_id: - stmt += lambda q: q.filter(States.metadata_id == metadata_id) - if join_attributes: - stmt += lambda q: q.outerjoin( + if end_time_ts: + stmt = stmt.filter(States.last_updated_ts < end_time_ts) + if not no_attributes: + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - if descending: - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts.desc()) - else: - stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) if limit: - stmt += lambda q: q.limit(limit) - return stmt + stmt = stmt.limit(limit) + stmt = stmt.order_by( + States.metadata_id, + States.last_updated_ts, + ) + if not include_start_time_state or not run_start_ts: + return stmt + return _select_from_subquery( + union_all( + _select_from_subquery( + _get_single_entity_start_time_stmt( + start_time_ts, + single_metadata_id, + no_attributes, + False, + ).subquery(), + no_attributes, + False, + ), + _select_from_subquery( + stmt.subquery(), + no_attributes, + False, + ), + ).subquery(), + no_attributes, + False, + ) def state_changes_during_period( @@ -365,51 +383,64 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None + if not entity_id: + raise ValueError("entity_id must be provided") + entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: - metadata_id: int | None = None - entity_id_to_metadata_id = None - if entity_id: - instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session, False) - if metadata_id is None: - return {} - entity_id_to_metadata_id = {entity_id: metadata_id} - stmt = _state_changed_during_period_stmt( - start_time, - end_time, - metadata_id, - no_attributes, - descending, - limit, - ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_id else start_time, end_time + instance = recorder.get_instance(hass) + if not ( + possible_metadata_id := instance.states_meta_manager.get( + entity_id, session, False + ) + ): + return {} + single_metadata_id = possible_metadata_id + entity_id_to_metadata_id: dict[str, int | None] = { + entity_id: single_metadata_id + } + run_start_ts: float | None = None + if include_start_time_state and not ( + run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + ): + include_start_time_state = False + start_time_ts = dt_util.utc_to_timestamp(start_time) + end_time_ts = datetime_to_timestamp_or_none(end_time) + stmt = lambda_stmt( + lambda: _state_changed_during_period_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + no_attributes, + limit, + include_start_time_state, + run_start_ts, + ), + track_on=[ + bool(end_time_ts), + no_attributes, + bool(limit), + include_start_time_state, + ], ) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( - hass, - session, - states, - start_time, + execute_stmt_lambda_element( + session, stmt, None, end_time, orm_rows=False + ), + start_time_ts if include_start_time_state else None, entity_ids, - entity_id_to_metadata_id, # type: ignore[arg-type] - include_start_time_state=include_start_time_state, + entity_id_to_metadata_id, + descending=descending, ), ) -def _get_last_state_changes_stmt( - number_of_states: int, metadata_id: int -) -> StatementLambdaElement: - stmt, join_attributes = _lambda_stmt_and_join_attributes( - False, include_last_changed=False - ) - if number_of_states == 1: - stmt += lambda q: q.join( +def _get_last_state_changes_single_stmt(metadata_id: int) -> Select: + return ( + _stmt_and_join_attributes(False, False) + .join( ( lastest_state_for_metadata_id := ( select( @@ -429,8 +460,19 @@ def _get_last_state_changes_stmt( == lastest_state_for_metadata_id.c.max_last_updated, ), ) - else: - stmt += lambda q: q.where( + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .order_by(States.state_id.desc()) + ) + + +def _get_last_state_changes_multiple_stmt( + number_of_states: int, metadata_id: int +) -> Select: + return ( + _stmt_and_join_attributes(False, False) + .where( States.state_id == ( select(States.state_id) @@ -440,13 +482,11 @@ def _get_last_state_changes_stmt( .subquery() ).c.state_id ) - if join_attributes: - stmt += lambda q: q.outerjoin( + .outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - - stmt += lambda q: q.order_by(States.state_id.desc()) - return stmt + .order_by(States.state_id.desc()) + ) def get_last_state_changes( @@ -463,41 +503,48 @@ def get_last_state_changes( with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not ( - metadata_id := instance.states_meta_manager.get(entity_id, session, False) + possible_metadata_id := instance.states_meta_manager.get( + entity_id, session, False + ) ): return {} + metadata_id = possible_metadata_id entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} - stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) - states = list(execute_stmt_lambda_element(session, stmt)) + if number_of_states == 1: + stmt = lambda_stmt( + lambda: _get_last_state_changes_single_stmt(metadata_id), + ) + else: + stmt = lambda_stmt( + lambda: _get_last_state_changes_multiple_stmt( + number_of_states, metadata_id + ), + ) + states = list(execute_stmt_lambda_element(session, stmt, orm_rows=False)) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( - hass, - session, reversed(states), - dt_util.utcnow(), + None, entity_ids, entity_id_to_metadata_id, - include_start_time_state=False, ), ) -def _get_states_for_entities_stmt( - run_start: datetime, - utc_point_in_time: datetime, +def _get_start_time_state_for_entities_stmt( + run_start_ts: float, + epoch_time: float, metadata_ids: list[int], no_attributes: bool, -) -> StatementLambdaElement: + include_last_changed: bool, +) -> Select: """Baked query to get states for specific entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( + stmt = _stmt_and_join_attributes_for_start_state( + no_attributes, include_last_changed + ).join( ( most_recent_states_for_entities_by_date := ( select( @@ -508,7 +555,7 @@ def _get_states_for_entities_stmt( ) .filter( (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) + & (States.last_updated_ts < epoch_time) ) .filter(States.metadata_id.in_(metadata_ids)) .group_by(States.metadata_id) @@ -522,153 +569,88 @@ def _get_states_for_entities_stmt( == most_recent_states_for_entities_by_date.c.max_last_updated, ), ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - return stmt + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) -def _get_states_for_all_stmt( - run_start: datetime, - utc_point_in_time: datetime, - filters: Filters | None, +def _get_run_start_ts_for_utc_point_in_time( + hass: HomeAssistant, utc_point_in_time: datetime +) -> float | None: + """Return the start time of a run.""" + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + if ( + run is not None + and (run_start := process_timestamp(run.start)) < utc_point_in_time + ): + return run_start.timestamp() + # History did not run before utc_point_in_time but we still + return None + + +def _get_start_time_state_stmt( + run_start_ts: float, + epoch_time: float, + single_metadata_id: int | None, + metadata_ids: list[int], no_attributes: bool, -) -> StatementLambdaElement: - """Baked query to get states for all entities.""" - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) - # We did not get an include-list of entities, query all states in the inner - # query, then filter out unwanted domains as well as applying the custom filter. - # This filtering can't be done in the inner query because the domain column is - # not indexed and we can't control what's in the custom filter. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.metadata_id) - .subquery() - ) - ), - and_( - States.metadata_id == most_recent_states_by_date.c.max_metadata_id, - States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, - ), - ) - stmt += _ignore_domains_filter - if filters and filters.has_config: - stmt = stmt.add_criteria( - lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] - track_on=[filters], - ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - stmt += lambda q: q.outerjoin( - StatesMeta, States.metadata_id == StatesMeta.metadata_id - ) - return stmt - - -def _get_rows_with_session( - hass: HomeAssistant, - session: Session, - utc_point_in_time: datetime, - entity_ids: list[str] | None = None, - entity_id_to_metadata_id: dict[str, int | None] | None = None, - run: RecorderRuns | None = None, - filters: Filters | None = None, - no_attributes: bool = False, -) -> Iterable[Row]: + include_last_changed: bool, +) -> Select: """Return the states at a specific point in time.""" - if entity_ids and len(entity_ids) == 1: - if not entity_id_to_metadata_id or not ( - metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) - ): - return [] - return execute_stmt_lambda_element( - session, - _get_single_entity_states_stmt( - utc_point_in_time, metadata_id, no_attributes - ), + if single_metadata_id: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + return _get_single_entity_start_time_stmt( + epoch_time, + single_metadata_id, + no_attributes, + include_last_changed, ) - - if run is None: - run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) - - if run is None or process_timestamp(run.start) > utc_point_in_time: - # History did not run before utc_point_in_time - return [] - # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - if entity_ids: - if not entity_id_to_metadata_id or not ( - metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) - ): - return [] - stmt = _get_states_for_entities_stmt( - run.start, utc_point_in_time, metadata_ids, no_attributes - ) - else: - stmt = _get_states_for_all_stmt( - run.start, utc_point_in_time, filters, no_attributes - ) - - return execute_stmt_lambda_element(session, stmt) + return _get_start_time_state_for_entities_stmt( + run_start_ts, + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) -def _get_single_entity_states_stmt( - utc_point_in_time: datetime, +def _get_single_entity_start_time_stmt( + epoch_time: float, metadata_id: int, - no_attributes: bool = False, -) -> StatementLambdaElement: + no_attributes: bool, + include_last_changed: bool, +) -> Select: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = _lambda_stmt_and_join_attributes( - no_attributes, include_last_changed=True - ) - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += ( - lambda q: q.filter( - States.last_updated_ts < utc_point_in_time_ts, + stmt = ( + _stmt_and_join_attributes_for_start_state(no_attributes, include_last_changed) + .filter( + States.last_updated_ts < epoch_time, States.metadata_id == metadata_id, ) .order_by(States.last_updated_ts.desc()) .limit(1) ) - if join_attributes: - stmt += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) - return stmt + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) def _sorted_states_to_dict( - hass: HomeAssistant, - session: Session, states: Iterable[Row], - start_time: datetime, - entity_ids: list[str] | None, - entity_id_to_metadata_id: dict[str, int | None] | None, - filters: Filters | None = None, - include_start_time_state: bool = True, + start_time_ts: float | None, + entity_ids: list[str], + entity_id_to_metadata_id: dict[str, int | None], minimal_response: bool = False, - no_attributes: bool = False, compressed_state_format: bool = False, + descending: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -683,7 +665,8 @@ def _sorted_states_to_dict( """ field_map = _FIELD_MAP state_class: Callable[ - [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] + [Row, dict[str, dict[str, Any]], float | None, str, str, float | None], + State | dict[str, Any], ] if compressed_state_format: state_class = row_to_compressed_state @@ -694,73 +677,51 @@ def _sorted_states_to_dict( attr_time = LAST_CHANGED_KEY attr_state = STATE_KEY - result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) - metadata_id_to_entity_id: dict[int, str] = {} - metadata_id_idx = field_map["metadata_id"] - # Set all entity IDs to empty lists in result set to maintain the order - if entity_ids is not None: - for ent_id in entity_ids: - result[ent_id] = [] - - if entity_id_to_metadata_id: - metadata_id_to_entity_id = { - v: k for k, v in entity_id_to_metadata_id.items() if v is not None - } - else: - metadata_id_to_entity_id = recorder.get_instance( - hass - ).states_meta_manager.get_metadata_id_to_entity_id(session) - + result: dict[str, list[State | dict[str, Any]]] = { + entity_id: [] for entity_id in entity_ids + } + metadata_id_to_entity_id: dict[int, str] = {} + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } # Get the states at the start time - initial_states: dict[int, Row] = {} - if include_start_time_state: - initial_states = { - row[metadata_id_idx]: row - for row in _get_rows_with_session( - hass, - session, - start_time, - entity_ids, - entity_id_to_metadata_id, - filters=filters, - no_attributes=no_attributes, - ) - } - - if entity_ids and len(entity_ids) == 1: - if not entity_id_to_metadata_id or not ( - metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) - ): - return {} + if len(entity_ids) == 1: + metadata_id = entity_id_to_metadata_id[entity_ids[0]] + assert metadata_id is not None # should not be possible if we got here states_iter: Iterable[tuple[int, Iterator[Row]]] = ( (metadata_id, iter(states)), ) else: - key_func = itemgetter(metadata_id_idx) + key_func = itemgetter(field_map["metadata_id"]) states_iter = groupby(states, key_func) + state_idx = field_map["state"] + last_updated_ts_idx = field_map["last_updated_ts"] + # Append all changes to it for metadata_id, group in states_iter: + entity_id = metadata_id_to_entity_id[metadata_id] attr_cache: dict[str, dict[str, Any]] = {} - prev_state: Column | str | None = None - if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): - continue ent_results = result[entity_id] - if row := initial_states.pop(metadata_id, None): - prev_state = row.state - ent_results.append(state_class(row, attr_cache, start_time, entity_id=entity_id)) # type: ignore[call-arg] - if ( not minimal_response or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS ): ent_results.extend( - state_class(db_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + state_class( + db_state, + attr_cache, + start_time_ts, + entity_id, + db_state[state_idx], + db_state[last_updated_ts_idx], + ) for db_state in group ) continue + prev_state: str | None = None # With minimal response we only provide a native # State for the first and last response. All the states # in-between only provide the "state" and the @@ -768,14 +729,18 @@ def _sorted_states_to_dict( if not ent_results: if (first_state := next(group, None)) is None: continue - prev_state = first_state.state + prev_state = first_state[state_idx] ent_results.append( - state_class(first_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + state_class( + first_state, + attr_cache, + start_time_ts, + entity_id, + prev_state, # type: ignore[arg-type] + first_state[last_updated_ts_idx], + ) ) - state_idx = field_map["state"] - last_updated_ts_idx = field_map["last_updated_ts"] - # # minimal_response only makes sense with last_updated == last_updated # @@ -806,13 +771,9 @@ def _sorted_states_to_dict( if (state := row[state_idx]) != prev_state ) - # If there are no states beyond the initial state, - # the state a was never popped from initial_states - for metadata_id, row in initial_states.items(): - if entity_id := metadata_id_to_entity_id.get(metadata_id): - result[entity_id].append( - state_class(row, {}, start_time, entity_id=entity_id) # type: ignore[call-arg] - ) + if descending: + for ent_results in result.values(): + ent_results.reverse() # Filter out the empty lists if some states had 0 results. return {key: val for key, val in result.items() if val} diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index c64c38fb7e5..85190e25f4a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,9 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] + "requirements": [ + "sqlalchemy==2.0.12", + "fnv-hash-fast==0.3.1", + "psutil-home-assistant==0.0.1" + ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c487f0b70d7..b8436da97d5 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1158,23 +1158,23 @@ def _wipe_old_string_time_columns( elif engine.dialect.name == SupportedDialect.MYSQL: # # Since this is only to save space we limit the number of rows we update - # to 10,000,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out of innodb_buffer_pool_size on MySQL. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # - session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;")) + session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( text( "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 10000000;" + " LIMIT 100000;" ) ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: # # Since this is only to save space we limit the number of rows we update - # to 250,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out ram with postgresql. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # @@ -1182,7 +1182,7 @@ def _wipe_old_string_time_columns( text( "UPDATE events set time_fired=NULL " "where event_id in " - "(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);" + "(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -1190,7 +1190,7 @@ def _wipe_old_string_time_columns( text( "UPDATE states set last_updated=NULL, last_changed=NULL " "where state_id in " - "(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);" + "(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -1236,7 +1236,7 @@ def _migrate_columns_to_timestamp( "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) result = None @@ -1251,7 +1251,7 @@ def _migrate_columns_to_timestamp( "last_changed_ts=" "UNIX_TIMESTAMP(last_changed) " "where last_updated_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1264,9 +1264,9 @@ def _migrate_columns_to_timestamp( text( "UPDATE events SET " "time_fired_ts= " - "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired) end) " + "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) " "WHERE event_id IN ( " - "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 " + "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 " " );" ) ) @@ -1276,10 +1276,10 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated) end), " - "last_changed_ts=EXTRACT(EPOCH FROM last_changed) " + "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), " + "last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) " "where state_id IN ( " - "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 " + "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 " " );" ) ) @@ -1344,12 +1344,12 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" # nosec - "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start) end), " - "created_ts=EXTRACT(EPOCH FROM created), " - "last_reset_ts=EXTRACT(EPOCH FROM last_reset) " - "where id IN ( " - f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000 " - " );" + "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start::timestamptz) end), " + "created_ts=EXTRACT(EPOCH FROM created::timestamptz), " + "last_reset_ts=EXTRACT(EPOCH FROM last_reset::timestamptz) " + "where id IN (" + f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000" + ");" ) ) @@ -1481,6 +1481,7 @@ def migrate_event_type_ids(instance: Recorder) -> bool: event_type_to_id[ db_event_type.event_type ] = db_event_type.event_type_id + event_type_manager.clear_non_existent(db_event_type.event_type) session.execute( update(Events), diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index 91dd80c4aa2..1a204e767e3 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -8,6 +8,7 @@ from .context import ( uuid_hex_to_bytes_or_none, ) from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect +from .event import extract_event_type_ids from .state import LazyState, extract_metadata_ids, row_to_compressed_state from .statistics import ( CalendarStatisticPeriod, @@ -43,6 +44,7 @@ __all__ = [ "bytes_to_ulid_or_none", "bytes_to_uuid_hex_or_none", "datetime_to_timestamp_or_none", + "extract_event_type_ids", "extract_metadata_ids", "process_datetime_to_timestamp", "process_timestamp", diff --git a/homeassistant/components/recorder/models/event.py b/homeassistant/components/recorder/models/event.py new file mode 100644 index 00000000000..1d644b62f46 --- /dev/null +++ b/homeassistant/components/recorder/models/event.py @@ -0,0 +1,13 @@ +"""Models events in for Recorder.""" +from __future__ import annotations + + +def extract_event_type_ids( + event_type_to_event_type_id: dict[str, int | None], +) -> list[int]: + """Extract event_type ids from event_type_to_event_type_id.""" + return [ + event_type_id + for event_type_id in event_type_to_event_type_id.values() + if event_type_id is not None + ] diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index c26e5177720..398ad773ba2 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -13,18 +13,17 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State +import homeassistant.util.dt as dt_util -from .state_attributes import decode_attributes_from_row +from .state_attributes import decode_attributes_from_source from .time import ( process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, ) -# pylint: disable=invalid-name - -class LazyStatePreSchema31(State): +class LegacyLazyStatePreSchema31(State): """A lazy version of core State before schema 31.""" __slots__ = [ @@ -56,7 +55,9 @@ class LazyStatePreSchema31(State): def attributes(self) -> dict[str, Any]: """State attributes.""" if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + self._attributes = decode_attributes_from_row_legacy( + self._row, self.attr_cache + ) return self._attributes @attributes.setter @@ -138,7 +139,7 @@ class LazyStatePreSchema31(State): } -def row_to_compressed_state_pre_schema_31( +def legacy_row_to_compressed_state_pre_schema_31( row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, @@ -146,7 +147,7 @@ def row_to_compressed_state_pre_schema_31( """Convert a database row to a compressed state before schema 31.""" comp_state = { COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), } if start_time: comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() @@ -162,3 +163,137 @@ def row_to_compressed_state_pre_schema_31( row_changed_changed ) return comp_state + + +class LegacyLazyState(State): + """A lazy version of core State after schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed_ts", + "_last_updated_ts", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + entity_id: str | None = None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id = entity_id or self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_updated_ts: float | None = self._row.last_updated_ts or ( + dt_util.utc_to_timestamp(start_time) if start_time else None + ) + self._last_changed_ts: float | None = ( + self._row.last_changed_ts or self._last_updated_ts + ) + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row_legacy( + self._row, self.attr_cache + ) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + assert self._last_changed_ts is not None + return dt_util.utc_from_timestamp(self._last_changed_ts) + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed_ts = process_timestamp(value).timestamp() + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + assert self._last_updated_ts is not None + return dt_util.utc_from_timestamp(self._last_updated_ts) + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated_ts = process_timestamp(value).timestamp() + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + To be used for JSON serialization. + """ + last_updated_isoformat = self.last_updated.isoformat() + if self._last_changed_ts == self._last_updated_ts: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def legacy_row_to_compressed_state( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + entity_id: str | None = None, +) -> dict[str, Any]: + """Convert a database row to a compressed state schema 31 and later.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + else: + row_last_updated_ts: float = row.last_updated_ts + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + row_last_changed_ts := row.last_changed_ts + ) and row_last_updated_ts != row_last_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts + return comp_state + + +def decode_attributes_from_row_legacy( + row: Row, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a database row.""" + return decode_attributes_from_source( + getattr(row, "shared_attrs", None) or getattr(row, "attributes", None), + attr_cache, + ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 5594f5f6d43..523ffdf1852 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -16,11 +16,9 @@ from homeassistant.const import ( from homeassistant.core import Context, State import homeassistant.util.dt as dt_util -from .state_attributes import decode_attributes_from_row +from .state_attributes import decode_attributes_from_source from .time import process_timestamp -# pylint: disable=invalid-name - _LOGGER = logging.getLogger(__name__) @@ -51,20 +49,18 @@ class LazyState(State): self, row: Row, attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - entity_id: str | None = None, + start_time_ts: float | None, + entity_id: str, + state: str, + last_updated_ts: float | None, ) -> None: """Init the lazy state.""" self._row = row - self.entity_id = entity_id or self._row.entity_id - self.state = self._row.state or "" + self.entity_id = entity_id + self.state = state or "" self._attributes: dict[str, Any] | None = None - self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None - ) - self._last_changed_ts: float | None = ( - self._row.last_changed_ts or self._last_updated_ts - ) + self._last_updated_ts: float | None = last_updated_ts or start_time_ts + self._last_changed_ts: float | None = None self._context: Context | None = None self.attr_cache = attr_cache @@ -72,7 +68,9 @@ class LazyState(State): def attributes(self) -> dict[str, Any]: """State attributes.""" if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + self._attributes = decode_attributes_from_source( + getattr(self._row, "attributes", None), self.attr_cache + ) return self._attributes @attributes.setter @@ -95,7 +93,10 @@ class LazyState(State): @property def last_changed(self) -> datetime: """Last changed datetime.""" - assert self._last_changed_ts is not None + if self._last_changed_ts is None: + self._last_changed_ts = ( + getattr(self._row, "last_changed_ts", None) or self._last_updated_ts + ) return dt_util.utc_from_timestamp(self._last_changed_ts) @last_changed.setter @@ -138,21 +139,24 @@ class LazyState(State): def row_to_compressed_state( row: Row, attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - entity_id: str | None = None, + start_time_ts: float | None, + entity_id: str, + state: str, + last_updated_ts: float | None, ) -> dict[str, Any]: - """Convert a database row to a compressed state schema 31 and later.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + """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( + getattr(row, "attributes", None), attr_cache + ), } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) - else: - row_last_updated_ts: float = row.last_updated_ts - comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - row_changed_changed_ts := row.last_changed_ts - ) and row_last_updated_ts != row_changed_changed_ts: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts + 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 ( + (row_last_changed_ts := getattr(row, "last_changed_ts", None)) + and row_last_changed_ts + and row_last_updated_ts != row_last_changed_ts + ): + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts return comp_state diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py index 3ed109afa07..c9cc110e1e0 100644 --- a/homeassistant/components/recorder/models/state_attributes.py +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -5,21 +5,16 @@ from __future__ import annotations import logging from typing import Any -from sqlalchemy.engine.row import Row - from homeassistant.util.json import json_loads_object EMPTY_JSON_OBJECT = "{}" _LOGGER = logging.getLogger(__name__) -def decode_attributes_from_row( - row: Row, attr_cache: dict[str, dict[str, Any]] +def decode_attributes_from_source( + source: Any, attr_cache: dict[str, dict[str, Any]] ) -> dict[str, Any]: - """Decode attributes from a database row.""" - source: str | None = getattr(row, "shared_attrs", None) or getattr( - row, "attributes", None - ) + """Decode attributes from a row source.""" if not source or source == EMPTY_JSON_OBJECT: return {} if (attributes := attr_cache.get(source)) is not None: diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 02ba7545f89..09b113f03eb 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -52,7 +52,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] thread_name == "Recorder" or thread_name.startswith(DB_WORKER_PREFIX) ) - def _do_return_conn(self, record: ConnectionPoolEntry) -> Any: + def _do_return_conn(self, record: ConnectionPoolEntry) -> None: if self.recorder_or_dbworker: return super()._do_return_conn(record) record.close() @@ -72,8 +72,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] if self.recorder_or_dbworker: super().dispose() - # Any can be switched out for ConnectionPoolEntry in the next version of sqlalchemy - def _do_get(self) -> Any: + def _do_get(self) -> ConnectionPoolEntry: if self.recorder_or_dbworker: return super()._do_get() check_loop( @@ -83,7 +82,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] ) return self._do_get_db_connection_protected() - def _do_get_db_connection_protected(self) -> Any: + def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: report( ( "accesses the database without the database executor; " @@ -106,7 +105,7 @@ class MutexPool(StaticPool): _reference_counter = 0 pool_lock: threading.RLock - def _do_return_conn(self, record: ConnectionPoolEntry) -> Any: + def _do_return_conn(self, record: ConnectionPoolEntry) -> None: if DEBUG_MUTEX_POOL_TRACE: trace = traceback.extract_stack() trace_msg = "\n" + "".join(traceback.format_list(trace[:-1])) @@ -124,7 +123,7 @@ class MutexPool(StaticPool): ) MutexPool.pool_lock.release() - def _do_get(self) -> Any: + def _do_get(self) -> ConnectionPoolEntry: if DEBUG_MUTEX_POOL_TRACE: trace = traceback.extract_stack() trace_msg = "".join(traceback.format_list(trace[:-1])) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 662be41b1c8..95013de125d 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -34,6 +34,7 @@ from .queries import ( find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, + find_legacy_detached_states_and_attributes_to_purge, find_legacy_event_state_and_attributes_and_data_ids_to_purge, find_legacy_row, find_short_term_statistics_to_purge, @@ -146,7 +147,28 @@ def _purge_legacy_format( _purge_unused_attributes_ids(instance, session, attributes_ids) _purge_event_ids(session, event_ids) _purge_unused_data_ids(instance, session, data_ids) - return bool(event_ids or state_ids or attributes_ids or data_ids) + + # The database may still have some rows that have an event_id but are not + # linked to any event. These rows are not linked to any event because the + # event was deleted. We need to purge these rows as well or we will never + # switch to the new format which will prevent us from purging any events + # that happened after the detached states. + ( + detached_state_ids, + detached_attributes_ids, + ) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session, purge_before + ) + _purge_state_ids(instance, session, detached_state_ids) + _purge_unused_attributes_ids(instance, session, detached_attributes_ids) + return bool( + event_ids + or state_ids + or attributes_ids + or data_ids + or detached_state_ids + or detached_attributes_ids + ) def _purge_states_and_attributes_ids( @@ -412,6 +434,31 @@ def _select_short_term_statistics_to_purge( return [statistic.id for statistic in statistics] +def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session: Session, purge_before: datetime +) -> tuple[set[int], set[int]]: + """Return a list of state, and attribute ids to purge. + + We do not link these anymore since state_change events + do not exist in the events table anymore, however we + still need to be able to purge them. + """ + states = session.execute( + find_legacy_detached_states_and_attributes_to_purge( + dt_util.utc_to_timestamp(purge_before) + ) + ).all() + _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: + state_ids.add(state_id) + if attributes_id := state.attributes_id: + attributes_ids.add(attributes_id) + return state_ids, attributes_ids + + def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( session: Session, purge_before: datetime ) -> tuple[set[int], set[int], set[int], set[int]]: @@ -433,12 +480,12 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( data_ids = set() for event in events: event_ids.add(event.event_id) - if event.state_id: - state_ids.add(event.state_id) - if event.attributes_id: - attributes_ids.add(event.attributes_id) - if event.data_id: - data_ids.add(event.data_id) + if state_id := event.state_id: + state_ids.add(state_id) + if attributes_id := event.attributes_id: + attributes_ids.add(attributes_id) + if data_id := event.data_id: + data_ids.add(data_id) return event_ids, state_ids, attributes_ids, data_ids diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index f8a1b769d87..49f66fdcd68 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -678,6 +678,22 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) +def find_legacy_detached_states_and_attributes_to_purge( + purge_before: float, +) -> StatementLambdaElement: + """Find states rows with event_id set but not linked event_id in Events.""" + return lambda_stmt( + lambda: select(States.state_id, States.attributes_id) + .outerjoin(Events, States.event_id == Events.event_id) + .filter(States.event_id.isnot(None)) + .filter( + (States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None) + ) + .filter(Events.event_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + def find_legacy_row() -> StatementLambdaElement: """Check if there are still states in the table with an event_id.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0122ba4464b..57e572a49c7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -857,10 +857,13 @@ def _reduce_statistics( } if _want_mean: row["mean"] = mean(mean_values) if mean_values else None + mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None + min_values.clear() if _want_max: row["max"] = max(max_values) if max_values else None + max_values.clear() if _want_last_reset: row["last_reset"] = prev_stat.get("last_reset") if _want_state: @@ -868,10 +871,6 @@ def _reduce_statistics( if _want_sum: row["sum"] = prev_stat["sum"] result[statistic_id].append(row) - - max_values = [] - mean_values = [] - min_values = [] if _want_max and (_max := statistic.get("max")) is not None: max_values.append(_max) if _want_mean and (_mean := statistic.get("mean")) is not None: @@ -1034,18 +1033,19 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) if end_time is not None: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) @@ -1491,6 +1491,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1525,40 +1552,15 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table + start_time, end_time, metadata_ids, table, types + ) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) ) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) if not stats: return {} - # Return statistics combined with metadata - if period not in ("day", "week", "month"): - return _sorted_statistics_to_dict( - hass, - session, - stats, - statistic_ids, - metadata, - True, - table, - start_time, - units, - types, - ) result = _sorted_statistics_to_dict( hass, @@ -1573,6 +1575,10 @@ 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) @@ -1660,7 +1666,9 @@ def _get_last_statistics( stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) if not stats: return {} @@ -1751,7 +1759,9 @@ def get_latest_short_term_statistics( if statistic_id in metadata ] stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + stats = cast( + Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + ) if not stats: return {} @@ -1771,34 +1781,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" - return lambda_stmt( - lambda: columns.join( - ( - most_recent_statistic_ids := ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), - ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1809,27 +1819,39 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) -def _sorted_statistics_to_dict( +def _fast_build_sum_list( + stats_list: list[Row], + table_duration_seconds: float, + convert: Callable | None, + start_ts_idx: int, + sum_idx: int, +) -> list[StatisticsRow]: + """Build a list of sum statistics.""" + if convert: + return [ + { + "start": (start_ts := db_state[start_ts_idx]), + "end": start_ts + table_duration_seconds, + "sum": convert(db_state[sum_idx]), + } + for db_state in stats_list + ] + return [ + { + "start": (start_ts := db_state[start_ts_idx]), + "end": start_ts + table_duration_seconds, + "sum": db_state[sum_idx], + } + for db_state in stats_list + ] + + +def _sorted_statistics_to_dict( # noqa: C901 hass: HomeAssistant, session: Session, stats: Sequence[Row[Any]], @@ -1888,6 +1910,7 @@ def _sorted_statistics_to_dict( last_reset_ts_idx = field_map["last_reset_ts"] if "last_reset" in types else None state_idx = field_map["state"] if "state" in types else None sum_idx = field_map["sum"] if "sum" in types else None + sum_only = len(types) == 1 and sum_idx is not None # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, stats_list in stats_by_meta_id.items(): @@ -1900,6 +1923,23 @@ def _sorted_statistics_to_dict( convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) else: convert = None + + if sum_only: + # This function is extremely flexible and can handle all types of + # statistics, but in practice we only ever use a few combinations. + # + # For energy, we only need sum statistics, so we can optimize + # this path to avoid the overhead of the more generic function. + assert sum_idx is not None + result[statistic_id] = _fast_build_sum_list( + stats_list, + table_duration_seconds, + convert, + start_ts_idx, + sum_idx, + ) + continue + ent_results_append = result[statistic_id].append # # The below loop is a red hot path for energy, and every @@ -2274,7 +2314,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 250000;" + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" ) ) .rowcount @@ -2290,7 +2330,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: .execute( text( f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec - f"where id in (select id from {table} where start is not NULL LIMIT 250000)" + f"where id in (select id from {table} where start is not NULL LIMIT 100000)" ) ) .rowcount diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 4c661e3dc29..4e08719e572 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -97,7 +97,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for data_id, shared_data in execute_stmt_lambda_element( - session, get_shared_event_datas(hashs_chunk) + session, get_shared_event_datas(hashs_chunk), orm_rows=False ): results[shared_data] = self._id_map[shared_data] = cast( int, data_id diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 5b77e9116c7..d5541c547d5 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast +from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event @@ -12,6 +13,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids +from ..tasks import RefreshEventTypesTask from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: @@ -27,6 +29,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) + self._non_existent_event_types: LRU = LRU(CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. @@ -37,9 +40,12 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): self.get_many( {event.event_type for event in events if event.event_type is not None}, session, + True, ) - def get(self, event_type: str, session: Session) -> int | None: + def get( + self, event_type: str, session: Session, from_recorder: bool = False + ) -> int | None: """Resolve event_type to the event_type_id. This call is not thread-safe and must be called from the @@ -48,7 +54,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): return self.get_many((event_type,), session)[event_type] def get_many( - self, event_types: Iterable[str], session: Session + self, event_types: Iterable[str], session: Session, from_recorder: bool = False ) -> dict[str, int | None]: """Resolve event_types to event_type_ids. @@ -57,9 +63,14 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): """ results: dict[str, int | None] = {} missing: list[str] = [] + non_existent: list[str] = [] + for event_type in event_types: if (event_type_id := self._id_map.get(event_type)) is None: - missing.append(event_type) + if event_type in self._non_existent_event_types: + results[event_type] = None + else: + missing.append(event_type) results[event_type] = event_type_id @@ -69,12 +80,26 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): for event_type_id, event_type in execute_stmt_lambda_element( - session, find_event_type_ids(missing_chunk) + session, find_event_type_ids(missing_chunk), orm_rows=False ): results[event_type] = self._id_map[event_type] = cast( int, event_type_id ) + if non_existent := [ + event_type for event_type in missing if results[event_type] is None + ]: + if from_recorder: + # We are already in the recorder thread so we can update the + # non-existent event types directly. + for event_type in non_existent: + self._non_existent_event_types[event_type] = None + else: + # Queue a task to refresh the event types since its not + # thread-safe to do it here since we are not in the recorder + # thread. + self.recorder.queue_task(RefreshEventTypesTask(non_existent)) + return results def add_pending(self, db_event_type: EventTypes) -> None: @@ -95,8 +120,17 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): """ for event_type, db_event_types in self._pending.items(): self._id_map[event_type] = db_event_types.event_type_id + self.clear_non_existent(event_type) self._pending.clear() + def clear_non_existent(self, event_type: str) -> None: + """Clear a non-existent event type from the cache. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._non_existent_event_types.pop(event_type, None) + def evict_purged(self, event_types: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 51c626bd366..442277be96e 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -114,7 +114,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for attributes_id, shared_attrs in execute_stmt_lambda_element( - session, get_shared_attributes(hashs_chunk) + session, get_shared_attributes(hashs_chunk), orm_rows=False ): results[shared_attrs] = self._id_map[shared_attrs] = cast( int, attributes_id diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 639e0acaa3a..bc4a8cfd2d9 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -67,7 +67,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): cast( Sequence[tuple[int, str]], execute_stmt_lambda_element( - session, find_all_states_metadata_ids() + session, find_all_states_metadata_ids(), orm_rows=False ), ) ) diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index ba47b3600d6..75af59d7c7a 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -109,6 +109,7 @@ class StatisticsMetaManager: _generate_get_metadata_stmt( statistic_ids, statistic_type, statistic_source ), + orm_rows=False, ): statistics_meta = cast(StatisticsMeta, row) id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index dfa6ce32d25..07be6202a0c 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -17,7 +17,7 @@ from . import entity_registry, purge, statistics from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData -from .util import periodic_db_cleanups +from .util import periodic_db_cleanups, session_scope _LOGGER = logging.getLogger(__name__) @@ -466,3 +466,17 @@ class EventIdMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Clean up the legacy event_id index on states.""" instance._cleanup_legacy_states_event_ids() # pylint: disable=[protected-access] + + +@dataclass(slots=True) +class RefreshEventTypesTask(RecorderTask): + """An object to insert into the recorder queue to refresh event types.""" + + event_types: list[str] + + def run(self, instance: Recorder) -> None: + """Refresh event types.""" + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.event_type_manager.get_many( + self.event_types, session, from_recorder=True + ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4ec0a0c4501..1c50fd0a77c 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -20,6 +20,7 @@ from awesomeversion import ( import ciso8601 from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row +from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session @@ -198,6 +199,7 @@ def execute_stmt_lambda_element( start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int = DEFAULT_YIELD_STATES_ROWS, + orm_rows: bool = True, ) -> Sequence[Row] | Result: """Execute a StatementLambdaElement. @@ -210,10 +212,13 @@ def execute_stmt_lambda_element( specific entities) since they are usually faster with .all(). """ - executed = session.execute(stmt) use_all = not start_time or ((end_time or dt_util.utcnow()) - start_time).days <= 1 for tryno in range(RETRIES): try: + if orm_rows: + executed = session.execute(stmt) + else: + executed = session.connection().execute(stmt) if use_all: return executed.all() return executed.yield_per(yield_per) @@ -344,14 +349,14 @@ def move_away_broken_database(dbfile: str) -> None: os.rename(path, f"{path}{corrupt_postfix}") -def execute_on_connection(dbapi_connection: Any, statement: str) -> None: +def execute_on_connection(dbapi_connection: DBAPIConnection, statement: str) -> None: """Execute a single statement with a dbapi connection.""" cursor = dbapi_connection.cursor() cursor.execute(statement) cursor.close() -def query_on_connection(dbapi_connection: Any, statement: str) -> Any: +def query_on_connection(dbapi_connection: DBAPIConnection, statement: str) -> Any: """Execute a single statement with a dbapi connection and return the result.""" cursor = dbapi_connection.cursor() cursor.execute(statement) @@ -457,7 +462,7 @@ def _async_create_mariadb_range_index_regression_issue( def setup_connection_for_dialect( instance: Recorder, dialect_name: str, - dbapi_connection: Any, + dbapi_connection: DBAPIConnection, first_connection: bool, ) -> DatabaseEngine | None: """Execute statements needed for dialect connection.""" @@ -465,10 +470,10 @@ def setup_connection_for_dialect( slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: if first_connection: - old_isolation = dbapi_connection.isolation_level - dbapi_connection.isolation_level = None + old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] + dbapi_connection.isolation_level = None # type: ignore[attr-defined] execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL") - dbapi_connection.isolation_level = old_isolation + dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined] # WAL mode only needs to be setup once # instead of every time we open the sqlite connection # as its persistent and isn't free to call every time. @@ -672,6 +677,7 @@ def periodic_db_cleanups(instance: Recorder) -> None: _LOGGER.debug("WAL checkpoint") with instance.engine.connect() as connection: connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);")) + connection.execute(text("PRAGMA OPTIMIZE;")) @contextmanager diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index df42c519fe2..c52df1b25e3 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,7 +30,6 @@ from homeassistant.util.unit_conversion import ( VolumeConverter, ) -from .const import MAX_QUEUE_BACKLOG from .models import StatisticPeriod from .statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, @@ -504,7 +503,7 @@ def ws_info( recorder_info = { "backlog": backlog, - "max_backlog": MAX_QUEUE_BACKLOG, + "max_backlog": instance.max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, "recording": recording, diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff100..135205aa95d 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DIRECTION = "direction" CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ class RejseplanenTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index c0db562bd8c..83d86745d90 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 01881d6947a..5f916a2d140 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultEntity +from .entity import RenaultEntity from .renault_hub import RenaultHub diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/coordinator.py similarity index 100% rename from homeassistant/components/renault/renault_coordinator.py rename to homeassistant/components/renault/coordinator.py diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index e646a9648e3..a27c59cecfb 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/entity.py similarity index 96% rename from homeassistant/components/renault/renault_entities.py rename to homeassistant/components/renault/entity.py index 188d429016a..aa83c935957 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .renault_coordinator import RenaultDataUpdateCoordinator, T +from .coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 9580ea2b7d0..30e251dd30b 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN -from .renault_coordinator import RenaultDataUpdateCoordinator +from .coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 02cf6d5c6b0..1ec891a51e4 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 1433d1e74f5..90ad70521df 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -37,8 +37,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime from .const import DOMAIN -from .renault_coordinator import T -from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription +from .coordinator import T +from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 6ddfa733d8d..8dad658d7db 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ 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 @@ -23,6 +24,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -146,11 +148,13 @@ class ReolinkHost: "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) try: - await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): + await self._webhook_reachable.wait() except asyncio.TimeoutError: _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, + FIRST_ONVIF_TIMEOUT, ) ir.async_create_issue( self._hass, diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index c4923c0088b..0f80215d506 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,6 +57,7 @@ LIGHT_ENTITIES = ( key="ir_lights", name="Infra red lights in night mode", icon="mdi:led-off", + entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), is_on_fn=lambda api, ch: api.ir_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), @@ -66,7 +67,7 @@ LIGHT_ENTITIES = ( name="Status LED", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "status_led"), + supported_fn=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 73318f12be1..cad89ac48c1 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.10"] + "requirements": ["reolink-aio==0.5.13"] } diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index d1da30a01a8..6303bc58131 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -5,7 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum, TrackMethodEnum +from reolink_aio.api import ( + DayNightEnum, + Host, + SpotlightModeEnum, + StatusLedEnum, + TrackMethodEnum, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -90,6 +96,17 @@ SELECT_ENTITIES = ( value=lambda api, ch: TrackMethodEnum(api.auto_track_method(ch)).name, method=lambda api, ch, name: api.set_auto_tracking(ch, method=name), ), + ReolinkSelectEntityDescription( + key="status_led", + name="Status LED", + icon="mdi:lightning-bolt-circle", + translation_key="status_led", + entity_category=EntityCategory.CONFIG, + get_options=[state.name for state in StatusLedEnum], + supported=lambda api, ch: api.supported(ch, "doorbell_led"), + value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name, + method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index c36001e0377..d02dbb8ab4d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", + "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -83,6 +83,13 @@ "digitalfirst": "Digital first", "pantiltfirst": "Pan/tilt first" } + }, + "status_led": { + "state": { + "stayoff": "Stay off", + "auto": "Auto", + "alwaysonatnight": "Auto & always on at night" + } } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index a7ed9b6a98d..1a4deda17e3 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -98,6 +98,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.ptz_guard_enabled(ch), method=lambda api, ch, value: api.set_ptz_guard(ch, enable=value), ), + ReolinkSwitchEntityDescription( + key="doorbell_button_sound", + name="Doorbell button sound", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "doorbell_button_sound"), + value=lambda api, ch: api.doorbell_button_sound(ch), + method=lambda api, ch, value: api.set_volume(ch, doorbell_button_sound=value), + ), ) NVR_SWITCH_ENTITIES = ( diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 637e9da6f9c..b249b7536b5 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -43,7 +43,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_ENCODING, + CONF_SSL_CIPHER_LIST, COORDINATOR, + DEFAULT_SSL_CIPHER_LIST, DOMAIN, PLATFORM_IDX, REST, @@ -185,6 +187,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res method: str = config[CONF_METHOD] payload: str | None = config.get(CONF_PAYLOAD) verify_ssl: bool = config[CONF_VERIFY_SSL] + ssl_cipher_list: str = config.get(CONF_SSL_CIPHER_LIST, DEFAULT_SSL_CIPHER_LIST) username: str | None = config.get(CONF_USERNAME) password: str | None = config.get(CONF_PASSWORD) headers: dict[str, str] | None = config.get(CONF_HEADERS) @@ -218,5 +221,6 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res params, payload, verify_ssl, + ssl_cipher_list, timeout, ) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 320413a10a0..60d9a2d8504 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,6 +1,9 @@ """Support for RESTful binary sensors.""" from __future__ import annotations +import logging +import ssl + import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -31,6 +34,8 @@ from .data import RestData from .entity import RestEntity from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA +_LOGGER = logging.getLogger(__name__) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **BINARY_SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( @@ -59,6 +64,13 @@ async def async_setup_platform( if rest.data is None: if rest.last_exception: + if isinstance(rest.last_exception, ssl.SSLError): + _LOGGER.error( + "Error connecting %s failed with %s", + conf[CONF_RESOURCE], + rest.last_exception, + ) + return raise PlatformNotReady from rest.last_exception raise PlatformNotReady diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index bdc0c5af492..0bf0ea9743d 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -1,12 +1,16 @@ """The rest component constants.""" +from homeassistant.util.ssl import SSLCipherList + DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True +DEFAULT_SSL_CIPHER_LIST = SSLCipherList.PYTHON_DEFAULT DEFAULT_FORCE_UPDATE = False DEFAULT_ENCODING = "UTF-8" CONF_ENCODING = "encoding" +CONF_SSL_CIPHER_LIST = "ssl_cipher_list" DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" DEFAULT_SENSOR_NAME = "REST Sensor" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 7a5d62694b9..8f1dd937391 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging +import ssl import httpx from homeassistant.core import HomeAssistant from homeassistant.helpers import template from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.util.ssl import SSLCipherList DEFAULT_TIMEOUT = 10 @@ -28,6 +30,7 @@ class RestData: params: dict[str, str] | None, data: str | None, verify_ssl: bool, + ssl_cipher_list: str, timeout: int = DEFAULT_TIMEOUT, ) -> None: """Initialize the data object.""" @@ -41,6 +44,7 @@ class RestData: self._request_data = data self._timeout = timeout self._verify_ssl = verify_ssl + self._ssl_cipher_list = SSLCipherList(ssl_cipher_list) self._async_client: httpx.AsyncClient | None = None self.data: str | None = None self.last_exception: Exception | None = None @@ -54,7 +58,10 @@ class RestData: """Get the latest data from REST service with provided method.""" if not self._async_client: self._async_client = create_async_httpx_client( - self._hass, verify_ssl=self._verify_ssl, default_encoding=self._encoding + self._hass, + verify_ssl=self._verify_ssl, + default_encoding=self._encoding, + ssl_cipher_list=self._ssl_cipher_list, ) rendered_headers = template.render_complex(self._headers, parse_result=False) @@ -88,3 +95,11 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + except ssl.SSLError as ex: + if log_errors: + _LOGGER.error( + "Error connecting to %s failed with %s", self._resource, ex + ) + self.last_exception = ex + self.data = None + self.headers = None diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161c..b6ec7eb8ecb 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest", "name": "RESTful", - "codeowners": [], + "codeowners": ["@epenet"], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index 8e0fa9de00e..c5abe42d7fc 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -31,14 +31,17 @@ from homeassistant.helpers.template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, ) +from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + CONF_SSL_CIPHER_LIST, DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, + DEFAULT_SSL_CIPHER_LIST, DEFAULT_VERIFY_SSL, DOMAIN, METHODS, @@ -58,6 +61,10 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional( + CONF_SSL_CIPHER_LIST, + default=DEFAULT_SSL_CIPHER_LIST, + ): vol.In([e.value for e in SSLCipherList]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 07fef4c4eab..ead5a5893f4 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import ssl from xml.parsers.expat import ExpatError from jsonpath import jsonpath @@ -67,6 +68,13 @@ async def async_setup_platform( if rest.data is None: if rest.last_exception: + if isinstance(rest.last_exception, ssl.SSLError): + _LOGGER.error( + "Error connecting %s failed with %s", + conf[CONF_RESOURCE], + rest.last_exception, + ) + return raise PlatformNotReady from rest.last_exception raise PlatformNotReady diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 1cc2fe7317c..ed3d832cf0b 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -37,8 +37,8 @@ "title": "Configure options", "data": { "scan_interval": "How often to poll Risco (in seconds)", - "code_arm_required": "Require [%key:common::config_flow::data::pin%] to arm", - "code_disarm_required": "Require [%key:common::config_flow::data::pin%] to disarm" + "code_arm_required": "Require PIN to arm", + "code_disarm_required": "Require PIN to disarm" } }, "risco_to_ha": { diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index a165fc0b2f0..18fd30754e2 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -24,7 +24,7 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = timedelta(seconds=30) +UPDATE_INTERVAL = timedelta(minutes=2) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py new file mode 100644 index 00000000000..497d30b41cf --- /dev/null +++ b/homeassistant/components/roborock/__init__.py @@ -0,0 +1,77 @@ +"""The Roborock component.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.api import RoborockApiClient +from roborock.cloud_api import RoborockMqttClient +from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS +from .coordinator import RoborockDataUpdateCoordinator + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up roborock from a config entry.""" + _LOGGER.debug("Integration async setup entry: %s", entry.as_dict()) + + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + _LOGGER.debug("Getting home data") + home_data = await api_client.get_home_data(user_data) + _LOGGER.debug("Got home data %s", home_data) + devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices + # 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} + ) + network_results = await asyncio.gather( + *(mqtt_client.get_networking(device.duid) for device in devices) + ) + network_info = { + device.duid: result + for device, result in zip(devices, network_results) + if result is not None + } + await mqtt_client.async_disconnect() + 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, + ) + + 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: + """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() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py new file mode 100644 index 00000000000..fcfad6e8cd3 --- /dev/null +++ b/homeassistant/components/roborock/config_flow.py @@ -0,0 +1,113 @@ +"""Config flow for Roborock.""" +from __future__ import annotations + +import logging +from typing import Any + +from roborock.api import RoborockApiClient +from roborock.containers import UserData +from roborock.exceptions import ( + RoborockAccountDoesNotExist, + RoborockException, + RoborockInvalidCode, + RoborockInvalidEmail, + RoborockUrlException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_BASE_URL, CONF_ENTRY_CODE, CONF_USER_DATA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Roborock.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._username: str | None = None + self._client: RoborockApiClient | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + self._username = username + _LOGGER.debug("Requesting code for Roborock account") + self._client = RoborockApiClient(username) + try: + await self._client.request_code() + except RoborockAccountDoesNotExist: + errors["base"] = "invalid_email" + except RoborockUrlException: + errors["base"] = "unknown_url" + except RoborockInvalidEmail: + errors["base"] = "invalid_email_format" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return await self.async_step_code() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}), + errors=errors, + ) + + async def async_step_code( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + assert self._client + assert self._username + if user_input is not None: + code = user_input[CONF_ENTRY_CODE] + _LOGGER.debug("Logging into Roborock account using email provided code") + try: + login_data = await self._client.code_login(code) + except RoborockInvalidCode: + errors["base"] = "invalid_code" + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "unknown_roborock" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return self._create_entry(self._client, self._username, login_data) + + return self.async_show_form( + step_id="code", + data_schema=vol.Schema({vol.Required(CONF_ENTRY_CODE): str}), + errors=errors, + ) + + def _create_entry( + self, client: RoborockApiClient, username: str, user_data: UserData + ) -> FlowResult: + """Finished config flow and create entry.""" + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_USER_DATA: user_data.as_dict(), + CONF_BASE_URL: client.base_url, + }, + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py new file mode 100644 index 00000000000..61a9a70dd20 --- /dev/null +++ b/homeassistant/components/roborock/const.py @@ -0,0 +1,9 @@ +"""Constants for Roborock.""" +from homeassistant.const import Platform + +DOMAIN = "roborock" +CONF_ENTRY_CODE = "code" +CONF_BASE_URL = "base_url" +CONF_USER_DATA = "user_data" + +PLATFORMS = [Platform.VACUUM, Platform.SELECT] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py new file mode 100644 index 00000000000..433b46d2899 --- /dev/null +++ b/homeassistant/components/roborock/coordinator.py @@ -0,0 +1,86 @@ +"""Roborock Coordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.containers import ( + HomeDataDevice, + HomeDataProduct, + NetworkInfo, + RoborockLocalDeviceInfo, +) +from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient +from roborock.typing import DeviceProp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, 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], + ) -> 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 + + async def release(self) -> None: + """Disconnect from API.""" + await self.api.async_disconnect() + + async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None: + """Update device properties.""" + device_prop = await self.api.get_prop(device_info.device.duid) + if device_prop: + if device_info.props: + device_info.props.update(device_prop) + else: + device_info.props = device_prop + + async def _async_update_data(self) -> dict[str, DeviceProp]: + """Update data via library.""" + try: + await asyncio.gather( + *( + self._update_device_prop(device_info) + for device_info in self.devices_info.values() + ) + ) + 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() + } diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py new file mode 100644 index 00000000000..e544147e9b8 --- /dev/null +++ b/homeassistant/components/roborock/device.py @@ -0,0 +1,66 @@ +"""Support for Roborock device base class.""" + +from typing import Any + +from roborock.containers import Status +from roborock.typing import RoborockCommand + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + + +class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): + """Representation of a base a coordinated Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + device_info: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the coordinated Roborock Device.""" + super().__init__(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 + 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/manifest.json b/homeassistant/components/roborock/manifest.json index 00f90271cfe..7cb686f1851 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,6 +1,10 @@ { "domain": "roborock", "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "codeowners": ["@humbertogontijo", "@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/roborock", + "iot_class": "local_polling", + "loggers": ["roborock"], + "requirements": ["python-roborock==0.8.3"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py new file mode 100644 index 00000000000..0377cebd425 --- /dev/null +++ b/homeassistant/components/roborock/models.py @@ -0,0 +1,15 @@ +"""Roborock Models.""" +from dataclasses import dataclass + +from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.typing import DeviceProp + + +@dataclass +class RoborockHassDeviceInfo: + """A model to describe roborock devices.""" + + device: HomeDataDevice + network_info: NetworkInfo + product: HomeDataProduct + props: DeviceProp diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py new file mode 100644 index 00000000000..646c4904854 --- /dev/null +++ b/homeassistant/components/roborock/select.py @@ -0,0 +1,116 @@ +"""Support for Roborock select.""" +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 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.""" + + api_command: RoborockCommand + value_fn: Callable[[Status], str] + options_lambda: Callable[[str], list[int]] + + +@dataclass +class RoborockSelectDescription( + SelectEntityDescription, RoborockSelectDescriptionMixin +): + """Class to describe an Roborock select entity.""" + + +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 + ], + ), + 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 + ], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock select platform.""" + + coordinator: 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 description in SELECT_DESCRIPTIONS + ) + + +class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockSelectDescription + + 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) + + 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 + + @property + def current_option(self) -> str | None: + """Get the current status of the select entity from device_status.""" + return self.entity_description.value_fn(self._device_status) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json new file mode 100644 index 00000000000..6bd19787d20 --- /dev/null +++ b/homeassistant/components/roborock/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Roborock email address.", + "data": { + "username": "Email" + } + }, + "code": { + "description": "Type the verification code sent to your email", + "data": { + "code": "Verification code" + } + } + }, + "error": { + "invalid_code": "The code you entered was incorrect, please check it and try again.", + "invalid_email": "There is no account associated with the email you entered, please try again.", + "invalid_email_format": "There is an issue with the formatting of your email - please try again.", + "unknown_roborock": "There was an unknown roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "select": { + "mop_mode": { + "name": "Mop mode", + "state": { + "standard": "Standard", + "deep": "Deep", + "deep_plus": "Deep+", + "custom": "Custom" + } + }, + "mop_intensity": { + "name": "Mop intensity", + "state": { + "off": "Off", + "mild": "Mild", + "moderate": "Moderate", + "intense": "Intense", + "custom": "Custom" + } + } + } + } +} diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py new file mode 100644 index 00000000000..4306afb25e4 --- /dev/null +++ b/homeassistant/components/roborock/vacuum.py @@ -0,0 +1,173 @@ +"""Support for Roborock vacuum class.""" +from typing import Any + +from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode +from roborock.typing import RoborockCommand + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +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 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" +} + + +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][ + config_entry.entry_id + ] + async_add_entities( + RoborockVacuum(slugify(device_id), device_info, coordinator) + for device_id, device_info in coordinator.devices_info.items() + ) + + +class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): + """General Representation of a Roborock vacuum.""" + + _attr_icon = "mdi:robot-vacuum" + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.STATUS + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + _attr_fan_speed_list = RoborockFanPowerCode.values() + + def __init__( + self, + unique_id: str, + device: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize a vacuum.""" + StateVacuumEntity.__init__(self) + RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator) + + @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.""" + return self._device_status.battery + + @property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._device_status.fan_power + + @property + def error(self) -> str | None: + """Get the error str if an error code exists.""" + return self._device_status.error + + async def async_start(self) -> None: + """Start the vacuum.""" + await self.send(RoborockCommand.APP_START) + + async def async_pause(self) -> None: + """Pause the vacuum.""" + await self.send(RoborockCommand.APP_PAUSE) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum.""" + await self.send(RoborockCommand.APP_STOP) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Send vacuum back to base.""" + await self.send(RoborockCommand.APP_CHARGE) + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Spot clean.""" + await self.send(RoborockCommand.APP_SPOT) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate vacuum.""" + await self.send(RoborockCommand.FIND_ME) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set vacuum fan speed.""" + await self.send( + RoborockCommand.SET_CUSTOM_MODE, + [k for k, v in RoborockFanPowerCode.items() if v == fan_speed], + ) + await self.coordinator.async_request_refresh() + + async def async_start_pause(self): + """Start, pause or resume the cleaning task.""" + if self.state == STATE_CLEANING: + await self.async_pause() + else: + await self.async_start() + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner.""" + await self.send(command, params) diff --git a/homeassistant/components/ruuvi_gateway/manifest.json b/homeassistant/components/ruuvi_gateway/manifest.json index cf1c9e02af3..a9284893973 100644 --- a/homeassistant/components/ruuvi_gateway/manifest.json +++ b/homeassistant/components/ruuvi_gateway/manifest.json @@ -11,5 +11,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway", "iot_class": "local_polling", - "requirements": ["aioruuvigateway==0.0.2"] + "requirements": ["aioruuvigateway==0.1.0"] } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76b..55d0fbdfbdb 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -165,10 +165,9 @@ class DebouncedEntryReloader: LOGGER.debug("Calling debouncer to get a reload after cooldown") await self._debounced_reload.async_call() - @callback - def async_cancel(self) -> None: + async def async_shutdown(self) -> None: """Cancel any pending reload.""" - self._debounced_reload.async_cancel() + await self._debounced_reload.async_shutdown() async def _async_reload_entry(self) -> None: """Reload entry.""" @@ -228,7 +227,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # will be a race where the config flow will see the entry # as not loaded and may reload it debounced_reloader = DebouncedEntryReloader(hass, entry) - entry.async_on_unload(debounced_reloader.async_cancel) + entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) hass.data[DOMAIN][entry.entry_id] = bridge @@ -340,7 +339,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index fb00d58c7ac..2e5fcc27715 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import itertools -import logging from typing import Any, Literal import voluptuous as vol @@ -21,9 +20,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.collection import ( CollectionEntity, + DictStorageCollection, + DictStorageCollectionWebsocket, IDManager, - StorageCollection, - StorageCollectionWebsocket, + SerializedStorageCollection, YamlCollection, sync_entity_lifecycle, ) @@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: version=STORAGE_VERSION, minor_version=STORAGE_VERSION_MINOR, ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule) @@ -183,7 +182,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - StorageCollectionWebsocket( + DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ScheduleStorageCollection(StorageCollection): +class ScheduleStorageCollection(DictStorageCollection): """Schedules stored in storage.""" SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA) @@ -226,12 +225,12 @@ class ScheduleStorageCollection(StorageCollection): name: str = info[CONF_NAME] return name - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" self.SCHEMA(update_data) - return data | update_data + return item | update_data - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" if data := await super()._async_load_data(): data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]] diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index cbfd73e486e..9c4137c1bea 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -231,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclass +@dataclass(slots=True) class ScriptEntityConfig: """Container for prepared script entity configuration.""" diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 049b86e1064..cfe1a12a24f 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -10,7 +10,7 @@ from sense_energy import ( ) DOMAIN = "sense" -DEFAULT_TIMEOUT = 10 +DEFAULT_TIMEOUT = 30 ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" SENSE_DATA = "sense_data" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8ccf3aac714..72072d36031 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense_energy==0.11.1"] + "requirements": ["sense_energy==0.11.2"] } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4f56be77a94..d0fdc8a0886 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ class SensorEntity(Entity): else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric 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 892bc611b3d..e829c8a8e49 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -75,7 +75,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s` + Unit of measurement: `d`, `h`, `min`, `s`, `ms` """ ENUM = "enum" diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 262f7033a41..52792e1d1f2 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -197,13 +197,13 @@ "name": "Ozone" }, "pm1": { - "name": "Particulate matter 1 μm" + "name": "PM1" }, "pm10": { - "name": "Particulate matter 10 μm" + "name": "PM10" }, "pm25": { - "name": "Particulate matter 2.5 μm" + "name": "PM2.5" }, "power_factor": { "name": "Power factor" diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a55..924c660da15 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.16.0"] + "requirements": ["sentry-sdk==1.20.0"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 58fe7a4de0b..90c5bf59fa3 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["pillow==9.4.0"] + "requirements": ["pillow==9.5.0"] } diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 57de36ce415..4161a5f5357 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -94,7 +94,7 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except UnknownAuth: # pylint: disable=broad-except + except UnknownAuth: errors["base"] = "unknown" return info, errors diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f2a43accb0e..597ff953656 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -195,17 +195,10 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if block.type == "device": cfg_changed = block.cfgChanged + # Shelly TRV sends information about changing the configuration for no + # reason, reloading the config entry is not needed for it. if self.model == "SHTRV-01": - # Reloading the entry is not needed when the target temperature changes - if "targetTemp" in block.sensor_ids: - if self._last_target_temp != block.targetTemp: - self._last_cfg_changed = None - self._last_target_temp = block.targetTemp - # Reloading the entry is not needed when the mode changes - if "mode" in block.sensor_ids: - if self._last_mode != block.mode: - self._last_cfg_changed = None - self._last_mode = block.mode + self._last_cfg_changed = None # For dual mode bulbs ignore change if it is due to mode/effect change if self.model in DUAL_MODE_LIGHT_MODELS: diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index bd08e19c4e2..3dc26fe007a 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -18,6 +18,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonArrayType, load_json_array from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, DOMAIN, EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, @@ -27,6 +29,7 @@ from .const import ( SERVICE_INCOMPLETE_ALL, SERVICE_INCOMPLETE_ITEM, SERVICE_REMOVE_ITEM, + SERVICE_SORT, ) ATTR_COMPLETE = "complete" @@ -38,6 +41,9 @@ PERSISTENCE = ".shopping_list.json" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -111,6 +117,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Clear all completed items from the list.""" await data.async_clear_completed() + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await data.async_sort(call.data[ATTR_REVERSE]) + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -147,6 +157,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b clear_completed_items_service, schema=SERVICE_LIST_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) @@ -277,6 +293,16 @@ class ShoppingData: context=context, ) + async def async_sort(self, reverse=False, context=None): + """Sort items by name.""" + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) + self.hass.async_add_executor_job(self.save) + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "sorted"}, + context=context, + ) + async def async_load(self) -> None: """Load items.""" diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index 05dc05137c0..c519123a414 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -2,6 +2,10 @@ DOMAIN = "shopping_list" EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" +ATTR_REVERSE = "reverse" + +DEFAULT_REVERSE = False + SERVICE_ADD_ITEM = "add_item" SERVICE_REMOVE_ITEM = "remove_item" SERVICE_COMPLETE_ITEM = "complete_item" @@ -9,3 +13,4 @@ SERVICE_INCOMPLETE_ITEM = "incomplete_item" SERVICE_COMPLETE_ALL = "complete_all" SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_CLEAR_COMPLETED_ITEMS = "clear_completed_items" +SERVICE_SORT = "sort" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index c41bc1333dc..250912f49cd 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -56,3 +56,14 @@ incomplete_all: clear_completed_items: name: Clear completed items description: Clear completed items from the shopping list. + +sort: + name: Sort all items + description: Sort all items by name in the shopping list. + fields: + reverse: + name: Sort reverse + description: Whether to sort in reverse (descending) order. + default: false + selector: + boolean: diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index 31ae36f793d..befa2c5df92 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -16,7 +16,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hub try: - await hub.sia_client.start(reuse_port=True) + if hub.sia_client: + await hub.sia_client.start(reuse_port=True) except OSError as exc: raise ConfigEntryNotReady( f"SIA Server at port {entry.data[CONF_PORT]} could not start." diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 25d3f447a09..6a86ce81445 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -121,7 +121,9 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): Return True if the event was relevant for this entity. """ - new_state = self.entity_description.code_consequences.get(sia_event.code) + new_state = None + if sia_event.code: + new_state = self.entity_description.code_consequences[sia_event.code] if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index d060af133a5..715fa26eee9 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -130,7 +130,9 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity): Return True if the event was relevant for this entity. """ - new_state = self.entity_description.code_consequences.get(sia_event.code) + new_state = None + if sia_event.code: + new_state = self.entity_description.code_consequences[sia_event.code] if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 2c2fb0d2be9..fb8d20e1830 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -47,7 +47,7 @@ class SIAHub: self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) self._protocol: str = entry.data[CONF_PROTOCOL] self.sia_accounts: list[SIAAccount] | None = None - self.sia_client: SIAClient = None + self.sia_client: SIAClient | None = None @callback def async_setup_hub(self) -> None: @@ -70,7 +70,8 @@ class SIAHub: async def async_shutdown(self, _: Event | None = None) -> None: """Shutdown the SIA server.""" - await self.sia_client.stop() + if self.sia_client: + await self.sia_client.stop() async def async_create_and_fire_event(self, event: SIAEvent) -> None: """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. @@ -108,12 +109,15 @@ class SIAHub: if self.sia_client is not None: self.sia_client.accounts = self.sia_accounts return - self.sia_client = SIAClient( - host="", - port=self._port, - accounts=self.sia_accounts, - function=self.async_create_and_fire_event, - protocol=CommunicationsProtocol(self._protocol), + # the new client class method creates a subclass based on protocol, hence the type ignore + self.sia_client = ( + SIAClient( # pylint: disable=abstract-class-instantiated # type: ignore + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), + ) ) def _load_options(self) -> None: diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json index 299b2b63bc8..8029aa24cee 100644 --- a/homeassistant/components/sia/manifest.json +++ b/homeassistant/components/sia/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sia", "iot_class": "local_push", "loggers": ["pysiaalarm"], - "requirements": ["pysiaalarm==3.0.2"] + "requirements": ["pysiaalarm==3.1.1"] } diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index 439e67a5eb4..7ca48bdc46e 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -126,7 +126,7 @@ class SIABaseEntity(RestoreEntity): then update the availability and schedule the next unavailability check. """ _LOGGER.debug("Received event: %s", sia_event) - if int(sia_event.ri) not in (self.zone, SIA_HUB_ZONE): + if (int(sia_event.ri) if sia_event.ri else 0) not in (self.zone, SIA_HUB_ZONE): return relevant_event = self.update_state(sia_event) diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py index cf52122a499..e9db69041d6 100644 --- a/homeassistant/components/sia/utils.py +++ b/homeassistant/components/sia/utils.py @@ -1,10 +1,11 @@ """Helper functions for the SIA integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from pysiaalarm import SIAEvent +from pysiaalarm.utils import MessageTypes from homeassistant.util.dt import utcnow @@ -50,21 +51,24 @@ def get_unavailability_interval(ping: int) -> float: def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: """Create the attributes dict from a SIAEvent.""" + timestamp = event.timestamp if event.timestamp else utcnow() return { ATTR_ZONE: event.ri, ATTR_CODE: event.code, ATTR_MESSAGE: event.message, ATTR_ID: event.id, - ATTR_TIMESTAMP: event.timestamp.isoformat() - if event.timestamp - else utcnow().isoformat(), + ATTR_TIMESTAMP: timestamp.isoformat() + if isinstance(timestamp, datetime) + else timestamp, } def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: """Create a dict from the SIA Event for the HA Event.""" return { - "message_type": event.message_type.value, + "message_type": event.message_type.value + if isinstance(event.message_type, MessageTypes) + else event.message_type, "receiver": event.receiver, "line": event.line, "account": event.account, @@ -77,8 +81,8 @@ def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: "message": event.message, "x_data": event.x_data, "timestamp": event.timestamp.isoformat() - if event.timestamp - else utcnow().isoformat(), + if isinstance(event.timestamp, datetime) + else event.timestamp, "event_qualifier": event.event_qualifier, "event_type": event.event_type, "partition": event.partition, diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index a4f024a8c03..1b6fbe9548d 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["pillow==9.4.0", "simplehound==0.3"] + "requirements": ["pillow==9.5.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d9..101e7cb35fd 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DOMAIN: Final = "simplepush" DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680e..3e7fad8863f 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ class SimplePushNotificationService(BaseNotificationService): """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ class SimplePushNotificationService(BaseNotificationService): salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655acc..0f9db48e78c 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_SEED = 999 DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ class SimulatedSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index b77c249dd27..8b6deaa3c7a 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.2.3"] + "requirements": ["asyncsleepiq==1.3.4"] } diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1e929a5e642..1609dc2e116 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,7 +1,7 @@ """Support for SleepIQ foundation preset selection.""" from __future__ import annotations -from asyncsleepiq import BED_PRESETS, Side, SleepIQBed, SleepIQPreset +from asyncsleepiq import Side, SleepIQBed, SleepIQPreset from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -30,8 +30,6 @@ async def async_setup_entry( class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity): """Representation of a SleepIQ select entity.""" - _attr_options = list(BED_PRESETS) - def __init__( self, coordinator: SleepIQDataUpdateCoordinator, @@ -46,6 +44,7 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select if preset.side != Side.NONE: self._attr_name += f" {preset.side_full}" self._attr_unique_id += f"_{preset.side.value}" + self._attr_options = preset.options super().__init__(coordinator, bed) self._async_update_attrs() diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a8..828e4a68121 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ class SmappeeActuator(SwitchEntity): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce0..309669a8496 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,41 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .server import HomeAssistantSnapcast + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + try: + server = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except OSError as ex: + raise ConfigEntryNotReady( + f"Could not connect to Snapcast server at {host}:{port}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + + 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/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 00000000000..896d3f8b5a8 --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,63 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +from snapcast.control.server import CONTROL_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=CONTROL_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle first step.""" + errors = {} + if user_input: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=False + ) + except socket.gaierror: + errors["base"] = "invalid_host" + except OSError: + errors["base"] = "cannot_connect" + else: + await client.stop() + return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) + return self.async_show_form( + step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match( + { + CONF_HOST: (import_config[CONF_HOST]), + CONF_PORT: (import_config[CONF_PORT]), + } + ) + return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b9..ded57e6fb03 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -1,6 +1,7 @@ """Constants for Snapcast.""" +from homeassistant.const import Platform -DATA_KEY = "snapcast" +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] GROUP_PREFIX = "snapcast_group_" GROUP_SUFFIX = "Snapcast Group" @@ -15,3 +16,6 @@ SERVICE_SET_LATENCY = "set_latency" ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DOMAIN = "snapcast" +DEFAULT_TITLE = "Snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index bdcadc84e7c..8701fca0ad4 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,6 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e2..4fd7c587d40 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging -import socket -import snapcast.control from snapcast.control.server import CONTROL_PORT import voluptuous as vol @@ -14,10 +12,12 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform 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 ( @@ -25,7 +25,7 @@ from .const import ( ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, - DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -34,6 +34,7 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -41,19 +42,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) +def register_services(): + """Register snapcast services.""" platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") platform.async_register_entity_service( @@ -66,23 +65,55 @@ async def async_setup_platform( handle_set_latency, ) - try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) - except socket.gaierror: - _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return - # Note: Host part is needed, when using multiple snapservers +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the snapcast config entry.""" + snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + + register_services() + + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - devices: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid) for group in server.groups + snapcast_data.groups = [ + SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups ] - devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients) - hass.data[DATA_KEY] = devices - async_add_entities(devices) + snapcast_data.clients = [ + SnapcastClientDevice(client, hpid, config_entry.entry_id) + for client in snapcast_data.server.clients + ] + async_add_entities(snapcast_data.clients + snapcast_data.groups) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Snapcast platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def handle_async_join(entity, service_call): @@ -132,11 +163,9 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -211,10 +240,11 @@ class SnapcastClientDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, client, uid_part): + def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + self._entry_id = entry_id async def async_added_to_hass(self) -> None: """Subscribe to client events.""" @@ -263,11 +293,13 @@ class SnapcastClientDevice(MediaPlayerEntity): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): @@ -303,9 +335,10 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DOMAIN][self._entry_id].clients + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise TypeError("Master is not a client device. Can only join clients.") diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py new file mode 100644 index 00000000000..507ad6393a2 --- /dev/null +++ b/homeassistant/components/snapcast/server.py @@ -0,0 +1,15 @@ +"""Snapcast Integration.""" +from dataclasses import dataclass, field + +from snapcast.control import Snapserver + +from homeassistant.components.media_player import MediaPlayerEntity + + +@dataclass +class HomeAssistantSnapcast: + """Snapcast data stored in the Home Assistant data object.""" + + server: Snapserver + clients: list[MediaPlayerEntity] = field(default_factory=list) + groups: list[MediaPlayerEntity] = field(default_factory=list) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 00000000000..0087b70d820 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Snapcast YAML configuration is being removed", + "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index d4619fa3b3a..3d19de74f91 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -90,12 +90,9 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" - # Make sure MQTT is available and the entry is loaded - if not hass.config_entries.async_entries( - mqtt.DOMAIN - ) or not await hass.config_entries.async_wait_component( - hass.config_entries.async_entries(mqtt.DOMAIN)[0] - ): + + # 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 diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json index 960ff07b750..d65aa06ea0a 100644 --- a/homeassistant/components/solaredge_local/manifest.json +++ b/homeassistant/components/solaredge_local/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/solaredge_local", "iot_class": "local_polling", "loggers": ["solaredge_local"], - "requirements": ["solaredge-local==0.2.0"] + "requirements": ["solaredge-local==0.2.3"] } diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 35e5d758433..d0efcd0ec9b 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -290,7 +290,7 @@ class SolarEdgeSensor(SensorEntity): """Return the state attributes.""" if extra_attr := self.entity_description.extra_attribute: try: - return {extra_attr: self._data.info[self.entity_description.key]} + return {extra_attr: self._data.info.get(self.entity_description.key)} except KeyError: pass return None @@ -298,7 +298,7 @@ class SolarEdgeSensor(SensorEntity): def update(self) -> None: """Get the latest data from the sensor and update the state.""" self._data.update() - self._attr_native_value = self._data.data[self.entity_description.key] + self._attr_native_value = self._data.data.get(self.entity_description.key) class SolarEdgeData: diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 4b68030f843..ea0a16229c1 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -26,7 +26,11 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -43,11 +47,14 @@ from .const import ( SONOS_REBOOTED, SONOS_SPEAKER_ACTIVITY, SONOS_VANISHED, + SUB_FAIL_ISSUE_ID, + SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, UPNP_ST, ) from .exception import SonosUpdateError from .favorites import SonosFavorites +from .helpers import sync_get_visible_zones from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -226,6 +233,24 @@ class SonosDiscoveryManager: async def async_subscription_failed(now: datetime.datetime) -> None: """Fallback logic if the subscription callback never arrives.""" + addr, port = sub.event_listener.address + listener_address = f"{addr}:{port}" + if advertise_ip := soco_config.EVENT_ADVERTISE_IP: + listener_address += f" (advertising as {advertise_ip})" + ir.async_create_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="subscriptions_failed", + translation_placeholders={ + "device_ip": ip_address, + "listener_address": listener_address, + "sub_fail_url": SUB_FAIL_URL, + }, + ) + _LOGGER.warning( "Subscription to %s failed, attempting to poll directly", ip_address ) @@ -255,6 +280,11 @@ class SonosDiscoveryManager: """Create SonosSpeakers when subscription callbacks successfully arrive.""" _LOGGER.debug("Subscription to %s succeeded", ip_address) cancel_failure_callback() + ir.async_delete_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + ) _async_add_visible_zones(subscription_succeeded=True) sub.callback = _async_subscription_succeeded @@ -338,19 +368,12 @@ class SonosDiscoveryManager: self, now: datetime.datetime | None = None ) -> None: """Add and maintain Sonos devices from a manual configuration.""" - - def get_sync_attributes(soco: SoCo) -> set[SoCo]: - """Ensure I/O attributes are cached and return visible zones.""" - _ = soco.household_id - _ = soco.uid - return soco.visible_zones - for host in self.hosts: - ip_addr = socket.gethostbyname(host) + ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) try: visible_zones = await self.hass.async_add_executor_job( - get_sync_attributes, + sync_get_visible_zones, soco, ) except (OSError, SoCoException, Timeout) as ex: @@ -382,7 +405,7 @@ class SonosDiscoveryManager: break for host in self.hosts.copy(): - ip_addr = socket.gethostbyname(host) + 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) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 9476b361ae7..e42fb7d67c7 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -19,6 +19,9 @@ PLATFORMS = [ Platform.SWITCH, ] +SUB_FAIL_ISSUE_ID = "subscriptions_failed" +SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" + SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" SONOS_PLAYLISTS = "playlists" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index ac7b96ec965..0b51687a465 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -5,10 +5,8 @@ from abc import abstractmethod import datetime import logging -import soco.config as soco_config from soco.core import SoCo -from homeassistant.components import persistent_notification import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity @@ -17,8 +15,6 @@ from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError from .speaker import SonosSpeaker -SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" - _LOGGER = logging.getLogger(__name__) @@ -57,29 +53,6 @@ class SonosEntity(Entity): async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" if not self.speaker.subscriptions_failed: - if soco_config.EVENT_ADVERTISE_IP: - listener_msg = ( - f"{self.speaker.subscription_address}" - f" (advertising as {soco_config.EVENT_ADVERTISE_IP})" - ) - else: - listener_msg = self.speaker.subscription_address - message = ( - f"{self.speaker.zone_name} cannot reach {listener_msg}," - " falling back to polling, functionality may be limited" - ) - log_link_msg = f", see {SUB_FAIL_URL} for more details" - notification_link_msg = ( - f'.\n\nSee Sonos documentation' - " for more details." - ) - _LOGGER.warning(message + log_link_msg) - persistent_notification.async_create( - self.hass, - message + notification_link_msg, - "Sonos networking issue", - "sonos_subscriptions_failed", - ) self.speaker.subscriptions_failed = True await self.speaker.async_unsubscribe() try: diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 5f44b9bae6f..1005b6c7d6a 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -117,3 +117,10 @@ def hostname_to_uid(hostname: str) -> str: else: raise ValueError(f"{hostname} is not a sonos device.") return f"{UID_PREFIX}{baseuid}{UID_POSTFIX}" + + +def sync_get_visible_zones(soco: SoCo) -> set[SoCo]: + """Ensure I/O attributes are cached and return visible zones.""" + _ = soco.household_id + _ = soco.uid + return soco.visible_zones diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index e1b3c6c1133..4a05053940c 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"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index cb18ec43887..7e6c210a164 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,8 +1,8 @@ """Support to interface with Sonos players.""" from __future__ import annotations -from asyncio import run_coroutine_threadsafe import datetime +from functools import partial import logging from typing import Any @@ -14,11 +14,13 @@ from soco.core import ( PLAY_MODES, ) from soco.data_structures import DidlFavorite +from sonos_websocket.exception import SonosWebsocketError import voluptuous as vol from homeassistant.components import media_source, spotify from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ENQUEUE, BrowseMedia, MediaPlayerDeviceClass, @@ -491,8 +493,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Clear players playlist.""" self.coordinator.soco.clear_queue() - @soco_error() - def play_media( # noqa: C901 + async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player. @@ -505,28 +506,46 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ - # Use 'replace' as the default enqueue option - enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) - - if spotify.is_spotify_media_type(media_type): - media_type = spotify.resolve_spotify_media_type(media_type) - media_id = spotify.spotify_uri_from_media_browser_url(media_id) - is_radio = False if media_source.is_media_source_id(media_id): is_radio = media_id.startswith("media-source://radio_browser/") media_type = MediaType.MUSIC - media_id = ( - run_coroutine_threadsafe( - media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ), - self.hass.loop, - ) - .result() - .url + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id ) + media_id = async_process_play_media_url(self.hass, media.url) + + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + volume = kwargs.get("extra", {}).get("volume") + _LOGGER.debug("Playing %s using websocket audioclip", media_id) + try: + assert self.speaker.websocket + response, _ = await self.speaker.websocket.play_clip( + async_process_play_media_url(self.hass, media_id), + volume=volume, + ) + except SonosWebsocketError as exc: + raise HomeAssistantError( + f"Error when calling Sonos websocket: {exc}" + ) from exc + if response["success"]: + return + + if spotify.is_spotify_media_type(media_type): + media_type = spotify.resolve_spotify_media_type(media_type) + media_id = spotify.spotify_uri_from_media_browser_url(media_id) + + await self.hass.async_add_executor_job( + partial(self._play_media, media_type, media_id, is_radio, **kwargs) + ) + + @soco_error() + def _play_media( + self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any + ) -> None: + """Wrap sync calls to async_play_media.""" + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) if media_type == "favorite_item_id": favorite = self.speaker.favorites.lookup_by_item_id(media_id) diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index dc0958f6190..8a9b8e9af70 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -19,6 +19,7 @@ from .speaker import SonosSpeaker LEVEL_TYPES = { "audio_delay": (0, 5), "bass": (-10, 10), + "balance": (-100, 100), "treble": (-10, 10), "sub_gain": (-15, 15), "surround_level": (-15, 15), @@ -30,6 +31,40 @@ SocoFeatures = list[tuple[str, tuple[int, int]]] _LOGGER = logging.getLogger(__name__) +def _balance_to_number(state: tuple[int, int]) -> float: + """Represent a balance measure returned by SoCo as a number. + + SoCo returns a pair of volumes, one for the left side and one + for the right side. When the two are equal, sound is centered; + HA will show that as 0. When the left side is louder, HA will + show a negative value, and a positive value means the right + side is louder. Maximum absolute value is 100, which means only + one side produces sound at all. + """ + left, right = state + return (right - left) * 100 // max(right, left) + + +def _balance_from_number(value: float) -> tuple[int, int]: + """Convert a balance value from -100 to 100 into SoCo format. + + 0 becomes (100, 100), fully enabling both sides. Note that + the master volume control is separate, so this does not + turn up the speakers to maximum volume. Negative values + reduce the volume of the right side, and positive values + reduce the volume of the left side. -100 becomes (100, 0), + fully muting the right side, and +100 becomes (0, 100), + muting the left side. + """ + left = min(100, 100 - int(value)) + right = min(100, int(value) + 100) + return left, right + + +LEVEL_TO_NUMBER = {"balance": _balance_to_number} +LEVEL_FROM_NUMBER = {"balance": _balance_from_number} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -92,9 +127,11 @@ class SonosLevelEntity(SonosEntity, NumberEntity): @soco_error() def set_native_value(self, value: float) -> None: """Set a new value.""" - setattr(self.soco, self.level_type, value) + from_number = LEVEL_FROM_NUMBER.get(self.level_type, int) + setattr(self.soco, self.level_type, from_number(value)) @property def native_value(self) -> float: """Return the current value.""" - return cast(float, getattr(self.speaker, self.level_type)) + to_number = LEVEL_TO_NUMBER.get(self.level_type, int) + return cast(float, to_number(getattr(self.speaker, self.level_type))) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 638ede722f5..e576d3f7908 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -18,12 +18,14 @@ from soco.exceptions import SoCoException, SoCoUPnPException from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot +from sonos_websocket import SonosWebsocket from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -97,6 +99,7 @@ class SonosSpeaker: """Initialize a SonosSpeaker.""" self.hass = hass self.soco = soco + self.websocket: SonosWebsocket | None = None self.household_id: str = soco.household_id self.media = SonosMedia(hass, soco) self._plex_plugin: PlexPlugin | None = None @@ -142,6 +145,7 @@ class SonosSpeaker: self.volume: int | None = None self.muted: bool | None = None self.cross_fade: bool | None = None + self.balance: tuple[int, int] | None = None self.bass: int | None = None self.treble: int | None = None self.loudness: bool | None = None @@ -170,8 +174,13 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() - async def async_setup_dispatchers(self, entry: ConfigEntry) -> None: - """Connect dispatchers in async context during setup.""" + async def async_setup(self, entry: ConfigEntry) -> None: + """Complete setup in async context.""" + self.websocket = SonosWebsocket( + self.soco.ip_address, + player_id=self.soco.uid, + session=async_get_clientsession(self.hass), + ) dispatch_pairs: tuple[tuple[str, Callable[..., Any]], ...] = ( (SONOS_CHECK_ACTIVITY, self.async_check_activity), (SONOS_SPEAKER_ADDED, self.update_group_for_uid), @@ -198,7 +207,7 @@ class SonosSpeaker: self.media.poll_media() future = asyncio.run_coroutine_threadsafe( - self.async_setup_dispatchers(entry), self.hass.loop + self.async_setup(entry), self.hass.loop ) future.result(timeout=10) @@ -528,7 +537,10 @@ class SonosSpeaker: variables = event.variables if "volume" in variables: - self.volume = int(variables["volume"]["Master"]) + volume = variables["volume"] + self.volume = int(volume["Master"]) + if "LF" in volume and "RF" in volume: + self.balance = (int(volume["LF"]), int(volume["RF"])) if "mute" in variables: self.muted = variables["mute"]["Master"] == "1" diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb73e30421f..75c1b850146 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -10,5 +10,11 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "issues": { + "subscriptions_failed": { + "title": "Networking error: subscriptions failed", + "description": "Falling back to polling, functionality may be limited.\n\nSonos device at {device_ip} cannot reach Home Assistant at {listener_address}.\n\nSee our [documentation]({sub_fail_url}) for more information on how to solve this issue." + } } } diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 085146d4eff..7ca1533744c 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotipy==2.22.1"], + "requirements": ["spotipy==2.23.0"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index b63a9513818..a738952d2ca 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -95,6 +95,7 @@ def spotify_exception_handler(func): self._attr_available = False if exc.reason == "NO_ACTIVE_DEVICE": raise HomeAssistantError("No active playback device found") from None + raise HomeAssistantError(f"Spotify error: {exc.reason}") from exc return wrapper diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 92b640580eb..dd5480450e2 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -1,6 +1,8 @@ """The sql component.""" from __future__ import annotations +import logging + import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -24,6 +26,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS +from .util import redact_credentials + +_LOGGER = logging.getLogger(__name__) def validate_sql_select(value: str) -> str: @@ -85,6 +90,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SQL from a config entry.""" + _LOGGER.debug( + "Comparing %s and %s", + redact_credentials(entry.options.get(CONF_DB_URL)), + redact_credentials(get_instance(hass).db_url), + ) if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 1c1ed6adae4..7cbcbe73efa 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,12 +6,12 @@ from typing import Any import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -22,19 +22,32 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +OPTIONS_SCHEMA: vol.Schema = vol.Schema( { - vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), - vol.Optional(CONF_DB_URL): selector.TextSelector(), - vol.Required(CONF_COLUMN_NAME): selector.TextSelector(), - vol.Required(CONF_QUERY): selector.TextSelector( - selector.TextSelectorConfig(multiline=True) - ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector(), - vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional( + CONF_DB_URL, + ): selector.TextSelector(), + vol.Required( + CONF_COLUMN_NAME, + ): selector.TextSelector(), + vol.Required( + CONF_QUERY, + ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)), + vol.Optional( + CONF_UNIT_OF_MEASUREMENT, + ): selector.TextSelector(), + vol.Optional( + CONF_VALUE_TEMPLATE, + ): selector.TemplateSelector(), } ) +CONFIG_SCHEMA: vol.Schema = vol.Schema( + { + vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), + } +).extend(OPTIONS_SCHEMA.schema) + def validate_sql_select(value: str) -> str | None: """Validate that value is a SQL SELECT query.""" @@ -56,9 +69,17 @@ def validate_query(db_url: str, query: str, column: str) -> bool: _LOGGER.debug("Execution error %s", error) if sess: sess.close() + engine.dispose() raise ValueError(error) from error for res in result.mappings(): + if column not in res: + _LOGGER.debug("Column `%s` is not returned by the query", column) + if sess: + sess.close() + engine.dispose() + raise NoSuchColumnError(f"Column {column} is not returned by the query.") + data = res[column] _LOGGER.debug("Return value from query: %s", data) @@ -87,6 +108,7 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step.""" errors = {} + description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) @@ -103,6 +125,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: @@ -128,29 +153,27 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), errors=errors, + description_placeholders=description_placeholders, ) -class SQLOptionsFlowHandler(config_entries.OptionsFlow): +class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): """Handle SQL options.""" - def __init__(self, entry: config_entries.ConfigEntry) -> None: - """Initialize SQL options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage SQL options.""" errors = {} + description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.entry.options.get(CONF_NAME, self.entry.title) + name = self.options.get(CONF_NAME, self.config_entry.title) try: validate_sql_select(query) @@ -158,61 +181,35 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) + except NoSuchColumnError: + errors["column"] = "column_invalid" + description_placeholders = {"column": column} except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" else: - new_user_input = user_input - if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation: - new_user_input.pop(CONF_DB_URL) + recorder_db = get_instance(self.hass).db_url + _LOGGER.debug( + "db_url: %s, resolved db_url: %s, recorder: %s", + db_url, + db_url_for_validation, + recorder_db, + ) + if db_url and db_url_for_validation == recorder_db: + user_input.pop(CONF_DB_URL) return self.async_create_entry( - title="", data={ CONF_NAME: name, - **new_user_input, + **user_input, }, ) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_DB_URL, - description={ - "suggested_value": self.entry.options.get(CONF_DB_URL) - }, - ): selector.TextSelector(), - vol.Required( - CONF_QUERY, - description={"suggested_value": self.entry.options[CONF_QUERY]}, - ): selector.TextSelector( - selector.TextSelectorConfig(multiline=True) - ), - vol.Required( - CONF_COLUMN_NAME, - description={ - "suggested_value": self.entry.options[CONF_COLUMN_NAME] - }, - ): selector.TextSelector(), - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - description={ - "suggested_value": self.entry.options.get( - CONF_UNIT_OF_MEASUREMENT - ) - }, - ): selector.TextSelector(), - vol.Optional( - CONF_VALUE_TEMPLATE, - description={ - "suggested_value": self.entry.options.get( - CONF_VALUE_TEMPLATE - ) - }, - ): selector.TemplateSelector(), - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, user_input or self.options ), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 2fed7d97948..97eb337731a 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"], + "codeowners": ["@dgomes", "@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.7"] + "requirements": ["sqlalchemy==2.0.12"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index eb0e9c9c46b..2a8ea80580b 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -42,20 +42,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .models import SQLData -from .util import resolve_db_url +from .util import redact_credentials, resolve_db_url _LOGGER = logging.getLogger(__name__) _SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) -def redact_credentials(data: str) -> str: - """Redact credentials from string data.""" - return DB_URL_RE.sub("//****:****@", data) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 1e7aef4ffde..6888652cb4c 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -5,7 +5,8 @@ }, "error": { "db_url_invalid": "Database URL invalid", - "query_invalid": "SQL Query invalid" + "query_invalid": "SQL Query invalid", + "column_invalid": "The column `{column}` is not returned by the query" }, "step": { "user": { @@ -18,7 +19,7 @@ "value_template": "Value Template" }, "data_description": { - "db_url": "Database URL, leave empty to use default HA database", + "db_url": "Database URL, leave empty to use HA recorder database", "name": "Name that will be used for Config Entry and also the Sensor", "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", @@ -51,7 +52,8 @@ }, "error": { "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", - "query_invalid": "[%key:component::sql::config::error::query_invalid%]" + "query_invalid": "[%key:component::sql::config::error::query_invalid%]", + "column_invalid": "[%key:component::sql::config::error::column_invalid%]" } }, "issues": { diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 81d8cd9900c..3dd0990b241 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -1,12 +1,26 @@ """Utils for sql.""" from __future__ import annotations +import logging + from homeassistant.components.recorder import get_instance from homeassistant.core import HomeAssistant +from .const import DB_URL_RE + +_LOGGER = logging.getLogger(__name__) + + +def redact_credentials(data: str | None) -> str: + """Redact credentials from string data.""" + if not data: + return "none" + return DB_URL_RE.sub("//****:****@", data) + def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: """Return the db_url provided if not empty, otherwise return the recorder db_url.""" + _LOGGER.debug("db_url: %s", redact_credentials(db_url)) if db_url and not db_url.isspace(): return db_url return get_instance(hass).db_url diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index b98b8a39dfa..ea80a29d990 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -1,34 +1,31 @@ """The SRP Energy integration.""" -import logging - from srpenergy.client import SrpEnergyClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import SRP_ENERGY_DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the SRP Energy component from a config entry.""" - # Store an SrpEnergyClient object for your srp_energy to access - try: - srp_energy_client = SrpEnergyClient( - entry.data.get(CONF_ID), - entry.data.get(CONF_USERNAME), - entry.data.get(CONF_PASSWORD), - ) - hass.data[SRP_ENERGY_DOMAIN] = srp_energy_client - except Exception as ex: - _LOGGER.error("Unable to connect to Srp Energy: %s", str(ex)) - raise ConfigEntryNotReady from ex + api_account_id: str = entry.data[CONF_ID] + api_username: str = entry.data[CONF_USERNAME] + api_password: str = entry.data[CONF_PASSWORD] + + LOGGER.debug("Configuring client using account_id %s", api_account_id) + + api_instance = SrpEnergyClient( + api_account_id, + api_username, + api_password, + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = api_instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -37,7 +34,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.""" - # unload srp client - hass.data[SRP_ENERGY_DOMAIN] = None - # Remove config entry - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + 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/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index 2d5505b7631..c52574ff312 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -1,65 +1,85 @@ """Config flow for SRP Energy.""" -import logging +from __future__ import annotations + +from typing import Any from srpenergy.client import SrpEnergyClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError -from .const import CONF_IS_TOU, DEFAULT_NAME, SRP_ENERGY_DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_IS_TOU, DEFAULT_NAME, DOMAIN, LOGGER -class ConfigFlow(config_entries.ConfigFlow, domain=SRP_ENERGY_DOMAIN): - """Handle a config flow for SRP Energy.""" +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + srp_client = SrpEnergyClient( + data[CONF_ID], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + + is_valid = await hass.async_add_executor_job(srp_client.validate) + + LOGGER.debug("Is user input valid: %s", is_valid) + if not is_valid: + raise InvalidAuth + + return is_valid + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an SRP Energy config flow.""" VERSION = 1 - config = { - vol.Required(CONF_ID): str, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional(CONF_IS_TOU, default=False): bool, - } - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} + default_title: str = DEFAULT_NAME if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - if user_input is not None: + if self.hass.config.location_name: + default_title = self.hass.config.location_name + + if user_input: try: - srp_client = SrpEnergyClient( - user_input[CONF_ID], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - - is_valid = await self.hass.async_add_executor_job(srp_client.validate) - - if is_valid: - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) - - errors["base"] = "invalid_auth" - + await validate_input(self.hass, user_input) except ValueError: + # Thrown when the account id is malformed errors["base"] = "invalid_account" + except InvalidAuth: + errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + return self.async_create_entry(title=default_title, data=user_input) return self.async_show_form( - step_id="user", data_schema=vol.Schema(self.config), errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_IS_TOU, default=False): bool, + } + ), + errors=errors or {}, ) - async def async_step_import(self, import_config): - """Import from config.""" - # Validate config values - return await self.async_step_user(user_input=import_config) + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b1..5128dc48b35 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -1,15 +1,16 @@ """Constants for the SRP Energy integration.""" from datetime import timedelta +import logging -SRP_ENERGY_DOMAIN = "srp_energy" -DEFAULT_NAME = "SRP Energy" +LOGGER = logging.getLogger(__package__) + +DOMAIN = "srp_energy" +DEFAULT_NAME = "Home" CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" +PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) -SENSOR_NAME = "Usage" +SENSOR_NAME = "Energy Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e53..cdfd53d40a0 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,6 +1,7 @@ """Support for SRP Energy Sensor.""" -from datetime import datetime, timedelta -import logging +from __future__ import annotations + +from datetime import timedelta import async_timeout from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout @@ -14,30 +15,29 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( - ATTRIBUTION, + CONF_IS_TOU, DEFAULT_NAME, - ICON, + DOMAIN, + LOGGER, MIN_TIME_BETWEEN_UPDATES, + PHOENIX_TIME_ZONE, SENSOR_NAME, SENSOR_TYPE, - SRP_ENERGY_DOMAIN, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the SRP Energy Usage sensor.""" # API object stored here by __init__.py - is_time_of_use = False - api = hass.data[SRP_ENERGY_DOMAIN] - if entry and entry.data: - is_time_of_use = entry.data["is_tou"] + api = hass.data[DOMAIN][entry.entry_id] + is_time_of_use = entry.data[CONF_IS_TOU] async def async_update_data(): """Fetch data from API endpoint. @@ -45,10 +45,13 @@ async def async_setup_entry( This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ + LOGGER.debug("async_update_data enter") try: # Fetch srp_energy data - start_date = datetime.now() + timedelta(days=-1) - end_date = datetime.now() + phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) + end_date = dt_util.now(phx_time_zone) + start_date = end_date - timedelta(days=1) + async with async_timeout.timeout(10): hourly_usage = await hass.async_add_executor_job( api.usage, @@ -57,9 +60,22 @@ async def async_setup_entry( is_time_of_use, ) + LOGGER.debug( + "async_update_data: Received %s record(s) from %s to %s", + len(hourly_usage) if hourly_usage else "None", + start_date, + end_date, + ) + previous_daily_usage = 0.0 for _, _, _, kwh, _ in hourly_usage: previous_daily_usage += float(kwh) + + LOGGER.debug( + "async_update_data: previous_daily_usage %s", + previous_daily_usage, + ) + return previous_daily_usage except TimeoutError as timeout_err: raise UpdateFailed("Timeout communicating with API") from timeout_err @@ -68,7 +84,7 @@ async def async_setup_entry( coordinator = DataUpdateCoordinator( hass, - _LOGGER, + LOGGER, name="sensor", update_method=async_update_data, update_interval=MIN_TIME_BETWEEN_UPDATES, @@ -83,10 +99,11 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False - def __init__(self, coordinator): + def __init__(self, coordinator) -> None: """Initialize the SrpEntity class.""" self._name = SENSOR_NAME self.type = SENSOR_TYPE @@ -95,51 +112,32 @@ class SrpEntity(SensorEntity): self._state = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return f"{DEFAULT_NAME} {self._name}" @property - def unique_id(self): - """Return sensor unique_id.""" - return self.type - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the device.""" - if self._state: - return f"{self._state:.2f}" - return None + return self.coordinator.data @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property - def icon(self): - """Return icon.""" - return ICON - - @property - def usage(self): - """Return entity state.""" - if self.coordinator.data: - return f"{self.coordinator.data:.2f}" - return None - - @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success @property - def device_class(self): + def device_class(self) -> SensorDeviceClass: """Return the device class.""" return SensorDeviceClass.ENERGY @property - def state_class(self): + def state_class(self) -> SensorStateClass: """Return the state class.""" return SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index abff81c0551..570e79e4993 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -106,42 +106,20 @@ PRIMARY_MATCH_KEYS = [ _LOGGER = logging.getLogger(__name__) -@dataclass -class _HaServiceDescription: - """Keys added by HA.""" - - x_homeassistant_matching_domains: set[str] = field(default_factory=set) - - -@dataclass -class _SsdpServiceDescription: - """SSDP info with optional keys.""" +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" ssdp_usn: str ssdp_st: str + upnp: Mapping[str, Any] ssdp_location: str | None = None ssdp_nt: str | None = None ssdp_udn: str | None = None ssdp_ext: str | None = None ssdp_server: str | None = None ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - - -@dataclass -class _UpnpServiceDescription: - """UPnP info.""" - - upnp: Mapping[str, Any] - - -@dataclass -class SsdpServiceInfo( - _HaServiceDescription, - _SsdpServiceDescription, - _UpnpServiceDescription, - BaseServiceInfo, -): - """Prepared info from ssdp/upnp entries.""" + x_homeassistant_matching_domains: set[str] = field(default_factory=set) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d6..f4a87837878 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ CONF_SANDBOX = "sandbox" DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ class StarlingBalanceSensor(SensorEntity): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 79cd5ca3895..a1cc60da79e 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEGREE, EntityCategory, UnitOfDataRate, UnitOfTime +from homeassistant.const import ( + DEGREE, + PERCENTAGE, + EntityCategory, + UnitOfDataRate, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -119,4 +125,11 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]), ), + StarlinkSensorEntityDescription( + key="ping_drop_rate", + name="Ping Drop Rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.status["pop_ping_drop_rate"], + ), ) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 2629962565b..2b1b3223212 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -47,20 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): - """Representation of a Steam entity.""" - - _attr_attribution = "Data provided by Steam" - - def __init__(self, coordinator: SteamDataUpdateCoordinator) -> None: - """Initialize a Steam entity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - configuration_url="https://store.steampowered.com", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, - ) diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py new file mode 100644 index 00000000000..364f2e72328 --- /dev/null +++ b/homeassistant/components/steam_online/entity.py @@ -0,0 +1,24 @@ +"""Entity classes for the Steam integration.""" +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SteamDataUpdateCoordinator + + +class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): + """Representation of a Steam entity.""" + + _attr_attribution = "Data provided by Steam" + + def __init__(self, coordinator: SteamDataUpdateCoordinator) -> None: + """Initialize a Steam entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + configuration_url="https://store.steampowered.com", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 10e507775d0..d3ae69e2517 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import SteamEntity from .const import ( CONF_ACCOUNTS, DOMAIN, @@ -23,6 +22,7 @@ from .const import ( STEAM_STATUSES, ) from .coordinator import SteamDataUpdateCoordinator +from .entity import SteamEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 63199402194..5e34a567c9e 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -1,12 +1,12 @@ """Provide functionality to STT.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import asyncio from collections.abc import AsyncIterable -from dataclasses import asdict, dataclass +from dataclasses import asdict import logging -from typing import Any +from typing import Any, final from aiohttp import web from aiohttp.hdrs import istr @@ -15,14 +15,20 @@ from aiohttp.web_exceptions import ( HTTPNotFound, HTTPUnsupportedMediaType, ) +import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util import dt as dt_util, language as language_util from .const import ( + DATA_PROVIDERS, DOMAIN, AudioBitRates, AudioChannels, @@ -31,96 +37,124 @@ from .const import ( AudioSampleRates, SpeechResultState, ) +from .legacy import ( + Provider, + SpeechMetadata, + SpeechResult, + async_default_provider, + async_get_provider, + async_setup_legacy, +) + +__all__ = [ + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", + "AudioBitRates", + "AudioChannels", + "AudioCodecs", + "AudioFormats", + "AudioSampleRates", + "DOMAIN", + "Provider", + "SpeechToTextEntity", + "SpeechMetadata", + "SpeechResult", + "SpeechResultState", +] _LOGGER = logging.getLogger(__name__) @callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: - """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) +def async_default_engine(hass: HomeAssistant) -> str | None: + """Return the domain or entity id of the default engine.""" + return async_default_provider(hass) or next( + iter(hass.states.async_entity_ids(DOMAIN)), None + ) - return hass.data[DOMAIN][domain] + +@callback +def async_get_speech_to_text_entity( + hass: HomeAssistant, entity_id: str +) -> SpeechToTextEntity | None: + """Return stt entity.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + + return component.get_entity(entity_id) + + +@callback +def async_get_speech_to_text_engine( + hass: HomeAssistant, engine_id: str +) -> SpeechToTextEntity | Provider | None: + """Return stt entity or legacy provider.""" + if entity := async_get_speech_to_text_entity(hass, engine_id): + return entity + return async_get_provider(hass, engine_id) + + +@callback +def async_get_speech_to_text_languages(hass: HomeAssistant) -> set[str]: + """Return a set with the union of languages supported by stt engines.""" + languages = set() + + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] + for entity in component.entities: + for language_tag in entity.supported_languages: + languages.add(language_tag) + + for engine in legacy_providers.values(): + for language_tag in engine.supported_languages: + languages.add(language_tag) + + return languages async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" - providers = hass.data[DOMAIN] = {} + websocket_api.async_register_command(hass, websocket_list_engines) - async def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a TTS platform.""" - if p_config is None: - p_config = {} + component = hass.data[DOMAIN] = EntityComponent[SpeechToTextEntity]( + _LOGGER, DOMAIN, hass + ) - platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) - if platform is None: - return + component.register_shutdown() + platform_setups = async_setup_legacy(hass, config) - try: - provider = await platform.async_get_engine(hass, p_config, discovery_info) - if provider is None: - _LOGGER.error("Error setting up platform %s", p_type) - return + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) - provider.name = p_type - provider.hass = hass - - providers[provider.name] = provider - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform: %s", p_type) - return - - setup_tasks = [ - asyncio.create_task(async_setup_platform(p_type, p_config)) - for p_type, p_config in config_per_platform(config, DOMAIN) - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - - # Add discovery support - async def async_platform_discovered(platform, info): - """Handle for discovered platform.""" - await async_setup_platform(platform, discovery_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - - hass.http.register_view(SpeechToTextView(providers)) + hass.http.register_view(SpeechToTextView(hass.data[DATA_PROVIDERS])) return True -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) -class Provider(ABC): +class SpeechToTextEntity(RestoreEntity): """Represent a single STT provider.""" - hass: HomeAssistant | None = None - name: str | None = None + _attr_should_poll = False + __last_processed: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the provider entity.""" + if self.__last_processed is None: + return None + return self.__last_processed @property @abstractmethod @@ -152,13 +186,36 @@ class Provider(ABC): def supported_channels(self) -> list[AudioChannels]: """Return a list of supported channels.""" + async def async_internal_added_to_hass(self) -> None: + """Call when the provider entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_processed = state.state + + @final + async def internal_async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service. + + Only streaming content is allowed! + """ + self.__last_processed = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_process_audio_stream(metadata=metadata, stream=stream) + @abstractmethod async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service. - Only streaming of content are allow! + Only streaming content is allowed! """ @callback @@ -179,6 +236,7 @@ class Provider(ABC): class SpeechToTextView(HomeAssistantView): """STT view to generate a text from audio stream.""" + _legacy_provider_reported = False requires_auth = True url = "/api/stt/{provider}" name = "api:stt:provider" @@ -189,47 +247,117 @@ class SpeechToTextView(HomeAssistantView): async def post(self, request: web.Request, provider: str) -> web.Response: """Convert Speech (audio) to text.""" - if provider not in self.providers: + hass: HomeAssistant = request.app["hass"] + provider_entity: SpeechToTextEntity | None = None + if ( + not (provider_entity := async_get_speech_to_text_entity(hass, provider)) + and provider not in self.providers + ): raise HTTPNotFound() - stt_provider: Provider = self.providers[provider] # Get metadata try: - metadata = metadata_from_header(request) + metadata = _metadata_from_header(request) except ValueError as err: raise HTTPBadRequest(text=str(err)) from err - # Check format - if not stt_provider.check_metadata(metadata): - raise HTTPUnsupportedMediaType() + if not provider_entity: + stt_provider = self._get_provider(provider) - # Process audio stream - result = await stt_provider.async_process_audio_stream( - metadata, request.content - ) + # Check format + if not stt_provider.check_metadata(metadata): + raise HTTPUnsupportedMediaType() + + # Process audio stream + result = await stt_provider.async_process_audio_stream( + metadata, request.content + ) + else: + # Check format + if not provider_entity.check_metadata(metadata): + raise HTTPUnsupportedMediaType() + + # Process audio stream + result = await provider_entity.internal_async_process_audio_stream( + metadata, request.content + ) # Return result return self.json(asdict(result)) async def get(self, request: web.Request, provider: str) -> web.Response: """Return provider specific audio information.""" - if provider not in self.providers: + hass: HomeAssistant = request.app["hass"] + if ( + not (provider_entity := async_get_speech_to_text_entity(hass, provider)) + and provider not in self.providers + ): raise HTTPNotFound() - stt_provider: Provider = self.providers[provider] + + if not provider_entity: + stt_provider = self._get_provider(provider) + + return self.json( + { + "languages": stt_provider.supported_languages, + "formats": stt_provider.supported_formats, + "codecs": stt_provider.supported_codecs, + "sample_rates": stt_provider.supported_sample_rates, + "bit_rates": stt_provider.supported_bit_rates, + "channels": stt_provider.supported_channels, + } + ) return self.json( { - "languages": stt_provider.supported_languages, - "formats": stt_provider.supported_formats, - "codecs": stt_provider.supported_codecs, - "sample_rates": stt_provider.supported_sample_rates, - "bit_rates": stt_provider.supported_bit_rates, - "channels": stt_provider.supported_channels, + "languages": provider_entity.supported_languages, + "formats": provider_entity.supported_formats, + "codecs": provider_entity.supported_codecs, + "sample_rates": provider_entity.supported_sample_rates, + "bit_rates": provider_entity.supported_bit_rates, + "channels": provider_entity.supported_channels, } ) + def _get_provider(self, provider: str) -> Provider: + """Get provider. -def metadata_from_header(request: web.Request) -> SpeechMetadata: + Method for legacy providers. + This can be removed when we remove the legacy provider support. + """ + stt_provider = self.providers[provider] + + if not self._legacy_provider_reported: + self._legacy_provider_reported = True + report_issue = self._suggest_report_issue(provider, stt_provider) + # This should raise in Home Assistant Core 2023.9 + _LOGGER.warning( + "Provider %s (%s) is using a legacy implementation, " + "and should be updated to use the SpeechToTextEntity. Please " + "%s", + provider, + type(stt_provider), + report_issue, + ) + + return stt_provider + + def _suggest_report_issue(self, provider: str, provider_instance: object) -> str: + """Suggest to report an issue.""" + report_issue = "" + if "custom_components" in type(provider_instance).__module__: + report_issue = "report it to the custom integration author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + report_issue += f"+label%3A%22integration%3A+{provider}%22" + + return report_issue + + +def _metadata_from_header(request: web.Request) -> SpeechMetadata: """Extract STT metadata from header. X-Speech-Content: @@ -254,7 +382,7 @@ def metadata_from_header(request: web.Request) -> SpeechMetadata: for entry in data: key, _, value = entry.strip().partition("=") if key not in fields: - raise ValueError(f"Invalid field {key}") + raise ValueError(f"Invalid field: {key}") args[key] = value for field in fields: @@ -270,5 +398,52 @@ def metadata_from_header(request: web.Request) -> SpeechMetadata: sample_rate=args["sample_rate"], channel=args["channel"], ) - except TypeError as err: + except ValueError as err: raise ValueError(f"Wrong format of X-Speech-Content: {err}") from err + + +@websocket_api.websocket_command( + { + "type": "stt/engine/list", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@callback +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.""" + component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] + legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] + + country = msg.get("country") + language = msg.get("language") + providers = [] + provider_info: dict[str, Any] + + for entity in component.entities: + provider_info = { + "engine_id": entity.entity_id, + "supported_languages": entity.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, entity.supported_languages, country + ) + providers.append(provider_info) + + for engine_id, provider in legacy_providers.items(): + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, provider.supported_languages, country + ) + providers.append(provider_info) + + connection.send_message( + websocket_api.result_message(msg["id"], {"providers": providers}) + ) diff --git a/homeassistant/components/stt/const.py b/homeassistant/components/stt/const.py index c111aed82a4..c9f5eb13d17 100644 --- a/homeassistant/components/stt/const.py +++ b/homeassistant/components/stt/const.py @@ -2,6 +2,7 @@ from enum import Enum DOMAIN = "stt" +DATA_PROVIDERS = f"{DOMAIN}_providers" class AudioCodecs(str, Enum): diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py new file mode 100644 index 00000000000..f2a5854e567 --- /dev/null +++ b/homeassistant/components/stt/legacy.py @@ -0,0 +1,174 @@ +"""Handle legacy speech to text platforms.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_prepare_setup_platform + +from .const import ( + DATA_PROVIDERS, + DOMAIN, + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_default_provider(hass: HomeAssistant) -> str | None: + """Return the domain of the default provider.""" + if "cloud" in hass.data[DATA_PROVIDERS]: + return "cloud" + + return next(iter(hass.data[DATA_PROVIDERS]), None) + + +@callback +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: + """Return provider.""" + if domain: + return hass.data[DATA_PROVIDERS].get(domain) + + provider = async_default_provider(hass) + return hass.data[DATA_PROVIDERS][provider] if provider is not None else None + + +@callback +def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: + """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): + """Set up a TTS platform.""" + if p_config is None: + p_config = {} + + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: + _LOGGER.error("Unknown speech to text platform specified") + return + + try: + provider = await platform.async_get_engine(hass, p_config, discovery_info) + + provider.name = p_type + provider.hass = hass + + providers[provider.name] = provider + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform: %s", p_type) + return + + # Add discovery support + async def async_platform_discovered(platform, info): + """Handle for discovered platform.""" + await async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return [ + async_setup_platform(p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + ] + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState + + +class Provider(ABC): + """Represent a single STT provider.""" + + hass: HomeAssistant | None = None + name: str | None = None + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + @abstractmethod + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + + @property + @abstractmethod + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + + @property + @abstractmethod + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bit rates.""" + + @property + @abstractmethod + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported sample rates.""" + + @property + @abstractmethod + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + + @abstractmethod + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service. + + Only streaming of content are allow! + """ + + @callback + def check_metadata(self, metadata: SpeechMetadata) -> bool: + """Check if given metadata supported by this provider.""" + if ( + metadata.language not in self.supported_languages + or metadata.format not in self.supported_formats + or metadata.codec not in self.supported_codecs + or metadata.bit_rate not in self.supported_bit_rates + or metadata.sample_rate not in self.supported_sample_rates + or metadata.channel not in self.supported_channels + ): + return False + return True diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index b594f8f91be..73eb0fa4c07 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,7 +1,7 @@ { "domain": "stt", "name": "Speech-to-Text (STT)", - "codeowners": ["@pvizeli"], + "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", "integration_type": "entity", diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 72956228948..541b31eb0a3 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -14,10 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,6 +27,7 @@ SCAN_INTERVAL = timedelta(seconds=10) SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": Platform.COVER, "CONTROLLINGTHEGATE": Platform.COVER, + "CONTROLLINGTHEGARAGEDOOR": Platform.COVER, "LIGHTSWITCH": Platform.SWITCH, } SUPLA_FUNCTION_NONE = "NONE" @@ -154,58 +152,3 @@ async def discover_devices(hass, hass_config): # Load discovered devices for component_name, config in component_configs.items(): await async_load_platform(hass, component_name, DOMAIN, config, hass_config) - - -class SuplaChannel(CoordinatorEntity): - """Base class of a Supla Channel (an equivalent of HA's Entity).""" - - def __init__(self, config, server, coordinator): - """Init from config, hookup[ server and coordinator.""" - super().__init__(coordinator) - self.server_name = config["server_name"] - self.channel_id = config["channel_id"] - self.server = server - - @property - def channel_data(self): - """Return channel data taken from coordinator.""" - return self.coordinator.data.get(self.channel_id) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "supla-{}-{}".format( - self.channel_data["iodevice"]["gUIDString"].lower(), - self.channel_data["channelNumber"], - ) - - @property - def name(self) -> str | None: - """Return the name of the device.""" - return self.channel_data["caption"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.channel_data is None: - return False - if (state := self.channel_data.get("state")) is None: - return False - return state.get("connected") - - async def async_action(self, action, **add_pars): - """Run server action. - - Actions are currently hardcoded in components. - Supla's API enables autodiscovery - """ - _LOGGER.debug( - "Executing action %s on channel %d, params: %s", - action, - self.channel_data["id"], - add_pars, - ) - await self.server.execute_action(self.channel_data["id"], action, **add_pars) - - # Update state - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index c6c1d9c07db..53e57fe1854 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -10,12 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER" SUPLA_GATE = "CONTROLLINGTHEGATE" +SUPLA_GARAGE_DOOR = "CONTROLLINGTHEGARAGEDOOR" async def async_setup_platform( @@ -37,16 +39,16 @@ async def async_setup_platform( if device_name == SUPLA_SHUTTER: entities.append( - SuplaCover( + SuplaCoverEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], ) ) - elif device_name == SUPLA_GATE: + elif device_name in {SUPLA_GATE, SUPLA_GARAGE_DOOR}: entities.append( - SuplaGateDoor( + SuplaDoorEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -56,7 +58,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaCover(SuplaChannel, CoverEntity): +class SuplaCoverEntity(SuplaEntity, CoverEntity): """Representation of a Supla Cover.""" @property @@ -90,33 +92,33 @@ class SuplaCover(SuplaChannel, CoverEntity): await self.async_action("STOP") -class SuplaGateDoor(SuplaChannel, CoverEntity): - """Representation of a Supla gate door.""" +class SuplaDoorEntity(SuplaEntity, CoverEntity): + """Representation of a Supla door.""" @property def is_closed(self) -> bool | None: - """Return if the gate is closed or not.""" + """Return if the door is closed or not.""" state = self.channel_data.get("state") if state and "hi" in state: return state.get("hi") return None async def async_open_cover(self, **kwargs: Any) -> None: - """Open the gate.""" + """Open the door.""" if self.is_closed: await self.async_action("OPEN_CLOSE") async def async_close_cover(self, **kwargs: Any) -> None: - """Close the gate.""" + """Close the door.""" if not self.is_closed: await self.async_action("OPEN_CLOSE") async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the gate.""" + """Stop the door.""" await self.async_action("OPEN_CLOSE") async def async_toggle(self, **kwargs: Any) -> None: - """Toggle the gate.""" + """Toggle the door.""" await self.async_action("OPEN_CLOSE") @property diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py new file mode 100644 index 00000000000..ae0a627b538 --- /dev/null +++ b/homeassistant/components/supla/entity.py @@ -0,0 +1,63 @@ +"""Base class for Supla channels.""" +from __future__ import annotations + +import logging + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +_LOGGER = logging.getLogger(__name__) + + +class SuplaEntity(CoordinatorEntity): + """Base class of a Supla Channel (an equivalent of HA's Entity).""" + + def __init__(self, config, server, coordinator): + """Init from config, hookup[ server and coordinator.""" + super().__init__(coordinator) + self.server_name = config["server_name"] + self.channel_id = config["channel_id"] + self.server = server + + @property + def channel_data(self): + """Return channel data taken from coordinator.""" + return self.coordinator.data.get(self.channel_id) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "supla-{}-{}".format( + self.channel_data["iodevice"]["gUIDString"].lower(), + self.channel_data["channelNumber"], + ) + + @property + def name(self) -> str | None: + """Return the name of the device.""" + return self.channel_data["caption"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.channel_data is None: + return False + if (state := self.channel_data.get("state")) is None: + return False + return state.get("connected") + + async def async_action(self, action, **add_pars): + """Run server action. + + Actions are currently hardcoded in components. + Supla's API enables autodiscovery + """ + _LOGGER.debug( + "Executing action %s on channel %d, params: %s", + action, + self.channel_data["id"], + add_pars, + ) + await self.server.execute_action(self.channel_data["id"], action, **add_pars) + + # Update state + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 9c4c53c1e9f..b270f4300e1 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS, SuplaChannel +from . import DOMAIN, SUPLA_COORDINATORS, SUPLA_SERVERS +from .entity import SuplaEntity _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ async def async_setup_platform( server_name = device["server_name"] entities.append( - SuplaSwitch( + SuplaSwitchEntity( device, hass.data[DOMAIN][SUPLA_SERVERS][server_name], hass.data[DOMAIN][SUPLA_COORDINATORS][server_name], @@ -42,7 +43,7 @@ async def async_setup_platform( async_add_entities(entities) -class SuplaSwitch(SuplaChannel, SwitchEntity): +class SuplaSwitchEntity(SuplaEntity, SwitchEntity): """Representation of a Supla Switch.""" async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f892..12007e1741c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ CONF_START = "from" DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 102319cec93..ef64a86c6e8 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -5,6 +5,7 @@ import logging import voluptuous as vol +from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import Event, HomeAssistant, callback @@ -104,17 +105,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Unload a config entry.""" - # Unhide the wrapped entry if registered + """Unload a config entry. + + This will unhide the wrapped entity and restore assistant expose settings. + """ registry = er.async_get(hass) try: - entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID]) + switch_entity_id = er.async_validate_entity_id( + registry, entry.options[CONF_ENTITY_ID] + ) except vol.Invalid: # The source entity has been removed from the entity registry return - if not (entity_entry := registry.async_get(entity_id)): + if not (switch_entity_entry := registry.async_get(switch_entity_id)): return - if entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION: - registry.async_update_entity(entity_id, hidden_by=None) + # Unhide the wrapped entity + if switch_entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION: + registry.async_update_entity(switch_entity_id, hidden_by=None) + + switch_as_x_entries = er.async_entries_for_config_entry(registry, entry.entry_id) + if not switch_as_x_entries: + return + + switch_as_x_entry = switch_as_x_entries[0] + + # Restore assistant expose settings + expose_settings = exposed_entities.async_get_entity_settings( + hass, switch_as_x_entry.entity_id + ) + for assistant, settings in expose_settings.items(): + if (should_expose := settings.get("should_expose")) is None: + continue + exposed_entities.async_expose_entity( + hass, assistant, switch_entity_id, should_expose + ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 21a7b882442..a73271bdc83 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -99,14 +100,37 @@ class BaseEntity(Entity): {"entity_id": self._switch_entity_id}, ) - if not self._is_new_entity: + if not self._is_new_entity or not ( + wrapped_switch := registry.async_get(self._switch_entity_id) + ): return - wrapped_switch = registry.async_get(self._switch_entity_id) - if not wrapped_switch or wrapped_switch.name is None: - return + def copy_custom_name(wrapped_switch: er.RegistryEntry) -> None: + """Copy the name set by user from the wrapped entity.""" + if wrapped_switch.name is None: + return + registry.async_update_entity(self.entity_id, name=wrapped_switch.name) - registry.async_update_entity(self.entity_id, name=wrapped_switch.name) + def copy_expose_settings() -> None: + """Copy assistant expose settings from the wrapped entity. + + Also unexpose the wrapped entity if exposed. + """ + expose_settings = exposed_entities.async_get_entity_settings( + self.hass, self._switch_entity_id + ) + for assistant, settings in expose_settings.items(): + if (should_expose := settings.get("should_expose")) is None: + continue + exposed_entities.async_expose_entity( + self.hass, assistant, self.entity_id, should_expose + ) + exposed_entities.async_expose_entity( + self.hass, assistant, self._switch_entity_id, False + ) + + copy_custom_name(wrapped_switch) + copy_expose_settings() class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 422adf6c511..2be541c8106 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.2.1"] + "requirements": ["aioswitcher==3.3.0"] } diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9c96cfc4296..b5a2c7bfad5 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -12,6 +12,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( @@ -56,6 +57,7 @@ class SynoApi: self.network: SynoDSMNetwork = None self.security: SynoCoreSecurity = None self.storage: SynoStorage = None + self.photos: SynoPhotos = None self.surveillance_station: SynoSurveillanceStation = None self.system: SynoCoreSystem = None self.upgrade: SynoCoreUpgrade = None @@ -66,6 +68,7 @@ class SynoApi: self._with_information = True self._with_security = True self._with_storage = True + self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -163,6 +166,7 @@ class SynoApi: self._fetching_entities.get(SynoCoreSecurity.API_KEY) ) self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) + self._with_photos = bool(self._fetching_entities.get(SynoStorage.API_KEY)) self._with_upgrade = bool(self._fetching_entities.get(SynoCoreUpgrade.API_KEY)) self._with_utilisation = bool( self._fetching_entities.get(SynoCoreUtilization.API_KEY) @@ -180,6 +184,13 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_photos: + LOGGER.debug( + "Disable photos api from being updated or '%s'", self._entry.unique_id + ) + self.dsm.reset(self.photos) + self.photos = None + if not self._with_storage: LOGGER.debug( "Disable storage api from being updatedf or '%s'", self._entry.unique_id @@ -219,6 +230,10 @@ class SynoApi: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security + if self._with_photos: + LOGGER.debug("Enable photos api updates for '%s'", self._entry.unique_id) + self.photos = self.dsm.photos + if self._with_storage: LOGGER.debug("Enable storage api updates for '%s'", self._entry.unique_id) self.storage = self.dsm.storage diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 9342849b2fe..36eb37b7882 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping -from ipaddress import ip_address +from ipaddress import ip_address as ip import logging from typing import Any, cast from urllib.parse import urlparse @@ -38,6 +38,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.util.network import is_ip_address as is_ip from .const import ( CONF_DEVICE_TOKEN, @@ -99,14 +100,6 @@ def _ordered_shared_schema( } -def _is_valid_ip(text: str) -> bool: - try: - ip_address(text) - except ValueError: - return False - return True - - def format_synology_mac(mac: str) -> str: """Format a mac address to the format used by Synology DSM.""" return mac.replace(":", "").replace("-", "").upper() @@ -284,16 +277,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): break self._abort_if_unique_id_configured() - fqdn_with_ssl_verification = ( - existing_entry - and not _is_valid_ip(existing_entry.data[CONF_HOST]) - and existing_entry.data[CONF_VERIFY_SSL] - ) - if ( existing_entry + and is_ip(existing_entry.data[CONF_HOST]) + and is_ip(host) and existing_entry.data[CONF_HOST] != host - and not fqdn_with_ssl_verification + and ip(existing_entry.data[CONF_HOST]).version == ip(host).version ): _LOGGER.info( "Update host from '%s' to '%s' for NAS '%s' via discovery", diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 1149012cbb2..8060bce5c9b 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -3,6 +3,7 @@ "name": "Synology DSM", "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py new file mode 100644 index 00000000000..16db365f708 --- /dev/null +++ b/homeassistant/components/synology_dsm/media_source.py @@ -0,0 +1,231 @@ +"""Expose Synology DSM as a media source.""" +from __future__ import annotations + +import mimetypes + +from aiohttp import web +from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem +from synology_dsm.exceptions import SynologyDSMException + +from homeassistant.components import http +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .models import SynologyDSMData + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Synology media source.""" + entries = hass.config_entries.async_entries(DOMAIN) + hass.http.register_view(SynologyDsmMediaView(hass)) + return SynologyPhotosMediaSource(hass, entries) + + +class SynologyPhotosMediaSourceIdentifier: + """Synology Photos item identifier.""" + + def __init__(self, identifier: str) -> None: + """Split identifier into parts.""" + parts = identifier.split("/") + + self.unique_id = None + self.album_id = None + self.cache_key = None + self.file_name = None + + if parts: + self.unique_id = parts[0] + if len(parts) > 1: + self.album_id = parts[1] + if len(parts) > 2: + self.cache_key = parts[2] + if len(parts) > 3: + self.file_name = parts[3] + + +class SynologyPhotosMediaSource(MediaSource): + """Provide Synology Photos as media sources.""" + + name = "Synology Photos" + + def __init__(self, hass: HomeAssistant, entries: list[ConfigEntry]) -> None: + """Initialize Synology source.""" + super().__init__(DOMAIN) + self.hass = hass + self.entries = entries + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if not self.hass.data.get(DOMAIN): + raise BrowseError("Diskstation not initialized") + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Synology Photos", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_diskstations(item), + ], + ) + + async def _async_build_diskstations( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing different diskstations.""" + if not item.identifier: + ret = [] + for entry in self.entries: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=entry.unique_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=f"{entry.title} - {entry.unique_id}", + can_play=False, + can_expand=True, + ) + ) + return ret + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + + if identifier.album_id is None: + # Get Albums + try: + albums = await diskstation.api.photos.get_albums() + except SynologyDSMException: + return [] + + ret = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/0", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="All images", + can_play=False, + can_expand=True, + ) + ] + for album in albums: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.name, + can_play=False, + can_expand=True, + ) + ) + + return ret + + # Request items of album + # Get Items + album = SynoPhotosAlbum(int(identifier.album_id), "", 0) + try: + album_items = await diskstation.api.photos.get_items_from_album( + album, 0, 1000 + ) + except SynologyDSMException: + return [] + + ret = [] + for album_item in album_items: + mime_type, _ = mimetypes.guess_type(album_item.file_name) + assert isinstance(mime_type, str) + if mime_type.startswith("image/"): + # Force small small thumbnails + album_item.thumbnail_size = "sm" + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}", + media_class=MediaClass.IMAGE, + media_content_type=mime_type, + title=album_item.file_name, + can_play=True, + can_expand=False, + thumbnail=await self.async_get_thumbnail( + album_item, diskstation + ), + ) + ) + return ret + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + if identifier.album_id is None: + raise Unresolvable("No album id") + if identifier.file_name is None: + raise Unresolvable("No file name") + mime_type, _ = mimetypes.guess_type(identifier.file_name) + if not isinstance(mime_type, str): + raise Unresolvable("No file extension") + return PlayMedia( + f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}", + mime_type, + ) + + async def async_get_thumbnail( + self, item: SynoPhotosItem, diskstation: SynologyDSMData + ) -> str | None: + """Get thumbnail.""" + try: + thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item) + except SynologyDSMException: + return None + return str(thumbnail) + + +class SynologyDsmMediaView(http.HomeAssistantView): + """Synology Media Finder View.""" + + url = "/synology_dsm/{source_dir_id}/{location:.*}" + name = "synology_dsm" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the media view.""" + self.hass = hass + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.Response: + """Start a GET request.""" + if not self.hass.data.get(DOMAIN): + raise web.HTTPNotFound() + # location: {cache_key}/{filename} + cache_key, file_name = location.split("/") + image_id = cache_key.split("_")[0] + mime_type, _ = mimetypes.guess_type(file_name) + if not isinstance(mime_type, str): + raise web.HTTPNotFound() + diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + + item = SynoPhotosItem(image_id, "", "", "", cache_key, "") + try: + image = await diskstation.api.photos.download_item(item) + except SynologyDSMException as exc: + raise web.HTTPNotFound() from exc + return web.Response(body=image, content_type=mime_type) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index db0082daeab..29298647326 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -115,7 +115,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_size", translation_key="memory_size", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -125,7 +127,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_cached", translation_key="memory_cached", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -135,7 +139,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_available_swap", translation_key="memory_available_swap", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -144,7 +150,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_available_real", translation_key="memory_available_real", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -153,7 +161,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_total_swap", translation_key="memory_total_swap", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -162,7 +172,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="memory_total_real", translation_key="memory_total_real", - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -171,7 +183,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="network_up", translation_key="network_up", - native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, @@ -180,7 +194,9 @@ UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoCoreUtilization.API_KEY, key="network_down", translation_key="network_down", - native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + suggested_display_precision=1, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, @@ -197,7 +213,9 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoStorage.API_KEY, key="volume_size_total", translation_key="volume_size_total", - native_unit_of_measurement=UnitOfInformation.TERABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", entity_registry_enabled_default=False, @@ -207,7 +225,9 @@ STORAGE_VOL_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( api_key=SynoStorage.API_KEY, key="volume_size_used", translation_key="volume_size_used", - native_unit_of_measurement=UnitOfInformation.TERABYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", state_class=SensorStateClass.MEASUREMENT, @@ -354,26 +374,15 @@ class SynoDSMUtilSensor(SynoDSMSensor): attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): attr = attr() - if attr is None: - return None - - result: StateType = attr - # Data (RAM) - if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - result = round(attr / 1024.0**2, 1) - return result - - # Network - if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - result = round(attr / 1024.0, 1) - return result # CPU load average - if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: - result = round(attr / 100, 2) - return result + if ( + isinstance(attr, int) + and self.native_unit_of_measurement == ENTITY_UNIT_LOAD + ): + return round(attr / 100, 2) - return result + return attr # type: ignore[no-any-return] @property def available(self) -> bool: @@ -400,13 +409,6 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) - if attr is None: - return None - - # Data (disk space) - if self.native_unit_of_measurement == UnitOfInformation.TERABYTES: - return round(attr / 1024.0**4, 2) # type: ignore[no-any-return] - return attr # type: ignore[no-any-return] diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index bf18a9707a1..92903b1d2ae 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -29,7 +29,7 @@ } }, "reauth_confirm": { - "title": "Synology DSM [%key:common::config_flow::title::reauth%]", + "title": "Reauthenticate Synology DSM", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 9f45108d61e..3d149b3a40d 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -188,7 +188,7 @@ async def handle_info( ) -@dataclasses.dataclass() +@dataclasses.dataclass(slots=True) class SystemHealthRegistration: """Helper class to track platform registration.""" diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 5ed6abe75a8..e02d0421f8d 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.4"] + "requirements": ["psutil==5.9.5"] } diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 090835103f9..cd0dd00afe5 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -59,7 +59,7 @@ class TagIDManager(collection.IDManager): return suggestion -class TagStorageCollection(collection.StorageCollection): +class TagStorageCollection(collection.DictStorageCollection): """Tag collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -80,9 +80,9 @@ class TagStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[TAG_ID] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {**data, **self.UPDATE_SCHEMA(update_data)} + data = {**item, **self.UPDATE_SCHEMA(update_data)} # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() @@ -95,11 +95,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = TagIDManager() hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index d9f417b2c8d..bfa6d01032b 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -159,8 +159,16 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): self._removed_from_hass = False await super().async_added_to_hass() - async def discovery_callback(config: TasmotaEntityConfig) -> None: - """Handle discovery update.""" + @callback + def discovery_callback(config: TasmotaEntityConfig) -> None: + """Handle discovery update. + + If the config has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ _LOGGER.debug( "Got update for entity with hash: %s '%s'", self._discovery_hash, @@ -169,7 +177,7 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): if not self._tasmota_entity.config_same(config): # Changed payload: Notify component _LOGGER.debug("Updating component: %s", self.entity_id) - await self.discovery_update(config) + self.hass.async_create_task(self.discovery_update(config)) else: # Unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 1e0fdfacc8e..256773b714b 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -52,6 +52,7 @@ _VALID_STATES = [ STATE_CLOSING, "true", "false", + "none", ] CONF_POSITION_TEMPLATE = "position_template" @@ -238,6 +239,10 @@ class CoverTemplate(TemplateEntity, CoverEntity): @callback def _update_position(self, result): + if result is None: + self._position = None + return + try: state = float(result) except ValueError as err: @@ -256,6 +261,10 @@ class CoverTemplate(TemplateEntity, CoverEntity): @callback def _update_tilt(self, result): + if result is None: + self._tilt_value = None + return + try: state = float(result) except ValueError as err: diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index b5696003c94..72165ddbf59 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -1,40 +1,16 @@ """Trigger entity.""" from __future__ import annotations -import logging -from typing import Any - -from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIQUE_ID, -) -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE - -CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, -} -class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): +class TriggerEntity(TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator]): """Template entity based on trigger data.""" - domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None - def __init__( self, hass: HomeAssistant, @@ -42,107 +18,22 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - - entity_unique_id = config.get(CONF_UNIQUE_ID) - - self._unique_id: str | None - if entity_unique_id and coordinator.unique_id: - self._unique_id = f"{coordinator.unique_id}-{entity_unique_id}" - else: - self._unique_id = entity_unique_id - - self._config = config - - self._static_rendered = {} - self._to_render_simple = [] - self._to_render_complex: list[str] = [] - - for itm in ( - CONF_AVAILABILITY, - CONF_ICON, - CONF_NAME, - CONF_PICTURE, - ): - if itm not in config: - continue - - if config[itm].is_static: - self._static_rendered[itm] = config[itm].template - else: - self._to_render_simple.append(itm) - - if self.extra_template_keys is not None: - self._to_render_simple.extend(self.extra_template_keys) - - if self.extra_template_keys_complex is not None: - self._to_render_complex.extend(self.extra_template_keys_complex) - - # We make a copy so our initial render is 'unknown' and not 'unavailable' - self._rendered = dict(self._static_rendered) - self._parse_result = {CONF_AVAILABILITY} - - @property - def name(self): - """Name of the entity.""" - return self._rendered.get(CONF_NAME) - - @property - def unique_id(self): - """Return unique ID of the entity.""" - return self._unique_id - - @property - def device_class(self): - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def icon(self) -> str | None: - """Return icon.""" - return self._rendered.get(CONF_ICON) - - @property - def entity_picture(self) -> str | None: - """Return entity picture.""" - return self._rendered.get(CONF_PICTURE) - - @property - def available(self): - """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return extra attributes.""" - return self._rendered.get(CONF_ATTRIBUTES) + super(CoordinatorEntity, self).__init__(coordinator) + super().__init__(hass, config) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - template.attach(self.hass, self._config) await super().async_added_to_hass() + await super(CoordinatorEntity, self).async_added_to_hass() if self.coordinator.data is not None: self._process_data() - def restore_attributes(self, last_state: State) -> None: - """Restore attributes.""" - for conf_key, attr in CONF_TO_ATTRIBUTE.items(): - if conf_key not in self._config or attr not in last_state.attributes: - continue - self._rendered[conf_key] = last_state.attributes[attr] - - if CONF_ATTRIBUTES in self._config: - extra_state_attributes = {} - for attr in self._config[CONF_ATTRIBUTES]: - if attr not in last_state.attributes: - continue - extra_state_attributes[attr] = last_state.attributes[attr] - self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + def _set_unique_id(self, unique_id: str | None) -> None: + """Set unique id.""" + if unique_id and self.coordinator.unique_id: + self._unique_id = f"{self.coordinator.unique_id}-{unique_id}" + else: + self._unique_id = unique_id @callback def _process_data(self) -> None: @@ -154,33 +45,7 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): run_variables = self.coordinator.data["run_variables"] variables = {"this": this, **(run_variables or {})} - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = template.render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = template.render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered + self._render_templates(variables) self.async_set_context(self.coordinator.data["context"]) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 668467c88d4..2178930199d 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.1", "numpy==1.23.2", - "pillow==9.4.0" + "pillow==9.5.0" ] } diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 7dce5a429d8..1006a44d5d3 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Apple Inc.": "apple", + "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index e716192b8b4..d6df026bbd7 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.0"] + "requirements": ["pyTibber==0.27.1"] } diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 62d962ee526..7cb2c10425e 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -119,7 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = TimerStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -131,7 +130,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS ).async_setup(hass) @@ -163,7 +162,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class TimerStorageCollection(collection.StorageCollection): +class TimerStorageCollection(collection.DictStorageCollection): """Timer storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -180,9 +179,9 @@ class TimerStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {CONF_ID: data[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) + data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b7..7fe8630cc98 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ class TMBSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index ea5aab15344..98910d7af38 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -9,7 +9,7 @@ import uuid from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.endpoints import get_sync_url from todoist_api_python.headers import create_headers -from todoist_api_python.models import Label, Task +from todoist_api_python.models import Due, Label, Task import voluptuous as vol from homeassistant.components.calendar import ( @@ -17,8 +17,8 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEvent, ) -from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -122,6 +122,11 @@ async def async_setup_platform( coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) await coordinator.async_config_entry_first_refresh() + async def _shutdown_coordinator(_: Event) -> None: + await coordinator.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_coordinator) + # Setup devices: # Grab all projects. projects = await api.get_projects() @@ -590,25 +595,19 @@ class TodoistProjectData: for task in project_task_data: if task.due is None: continue - due_date = dt.parse_datetime( - task.due.datetime if task.due.datetime else task.due.date - ) - if not due_date: + start = get_start(task.due) + if start is None: continue - due_date = dt.as_utc(due_date) - if start_date < due_date < end_date: - due_date_value: datetime | date = due_date - midnight = dt.start_of_local_day(due_date) - if due_date == midnight: - # If the due date has no time data, return just the date so that it - # will render correctly as an all day event on a calendar. - due_date_value = due_date.date() - event = CalendarEvent( - summary=task.content, - start=due_date_value, - end=due_date_value + timedelta(days=1), - ) - events.append(event) + event = CalendarEvent( + summary=task.content, + start=start, + end=start + timedelta(days=1), + ) + if event.start_datetime_local >= end_date: + continue + if event.end_datetime_local < start_date: + continue + events.append(event) return events async def async_update(self) -> None: @@ -663,3 +662,15 @@ class TodoistProjectData: return self.event = event _LOGGER.debug("Updated %s", self._name) + + +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) + if not start: + return None + return dt.as_local(start) + if due.date: + return dt.parse_date(due.date) + return None diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 9606dc29a44..48090d75706 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -71,7 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_trigger_discovery(hass, discovered) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 9d723407764..280ae56bbd5 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.4"] + "requirements": ["tplink-omada-client==1.2.4"] } diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 4581e286819..9ed7922fa19 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -243,7 +243,9 @@ class TraccarScanner: return False await self._async_update() - async_track_time_interval(self._hass, self._async_update, self._scan_interval) + async_track_time_interval( + self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + ) return True async def _async_update(self, now=None): diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 3d7510b57b2..5d0b188f724 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -14,6 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +from homeassistant.util.limited_size_dict import LimitedSizeDict from . import websocket_api from .const import ( @@ -24,7 +25,6 @@ from .const import ( DEFAULT_STORED_TRACES, ) from .models import ActionTrace, BaseTrace, RestoredTrace -from .utils import LimitedSizeDict _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 517ef0a853a..0e29bbbff5f 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -10,9 +10,9 @@ ORDER_WORST_RATIO_FIRST = "worst_ratio_first" SUPPORTED_ORDER_MODES = { ORDER_NEWEST_FIRST: lambda torrents: sorted( - torrents, key=lambda t: t.addedDate, reverse=True + torrents, key=lambda t: t.date_added, reverse=True ), - ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.addedDate), + ORDER_OLDEST_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.date_added), ORDER_WORST_RATIO_FIRST: lambda torrents: sorted(torrents, key=lambda t: t.ratio), ORDER_BEST_RATIO_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.ratio, reverse=True diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json index 53441057a5c..17b3bbbf49b 100644 --- a/homeassistant/components/transmission/manifest.json +++ b/homeassistant/components/transmission/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/transmission", "iot_class": "local_polling", "loggers": ["transmissionrpc"], - "requirements": ["transmission-rpc==3.4.0"] + "requirements": ["transmission-rpc==4.1.5"] } diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 2c7bf24cdfd..184d05faeb0 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -117,9 +117,9 @@ class TransmissionSpeedSensor(TransmissionSensor): """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: b_spd = ( - float(data.downloadSpeed) + float(data.download_speed) if self._sub_type == "download" - else float(data.uploadSpeed) + else float(data.upload_speed) ) self._state = b_spd @@ -134,8 +134,8 @@ class TransmissionStatusSensor(TransmissionSensor): def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - upload = data.uploadSpeed - download = data.downloadSpeed + upload = data.upload_speed + download = data.download_speed if upload > 0 and download > 0: self._state = STATE_UP_DOWN elif upload > 0 and download == 0: @@ -199,8 +199,8 @@ def _torrents_info(torrents, order, limit, statuses=None): torrents = SUPPORTED_ORDER_MODES[order](torrents) for torrent in torrents[:limit]: info = infos[torrent.name] = { - "added_date": torrent.addedDate, - "percent_done": f"{torrent.percentDone * 100:.2f}", + "added_date": torrent.date_added, + "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, } diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index 66f4daf200f..34a88528411 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -26,12 +26,6 @@ remove_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -56,12 +50,6 @@ start_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -79,12 +67,6 @@ stop_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index ed1b2f185a2..e2c144d5423 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -20,7 +20,7 @@ } }, "error": { - "name_exists": "[%key:common::config_flow::data::name%] already exists", + "name_exists": "Name already exists", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index ed771d24581..3af3fe57e3c 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -118,7 +118,7 @@ class TransmissionSwitch(SwitchEntity): if self.type == "on_off": self._data = self._tm_client.api.data if self._data: - active = self._data.activeTorrentCount > 0 + active = self._data.active_torrent_count > 0 elif self.type == "turtle_mode": active = self._tm_client.api.get_alt_speed_enabled() diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index aa8864ad23d..a90a69edcdb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1,25 +1,26 @@ """Provide functionality for TTS.""" from __future__ import annotations +from abc import abstractmethod import asyncio from collections.abc import Mapping -import functools as ft +from datetime import datetime +from functools import partial import hashlib from http import HTTPStatus import io import logging import mimetypes import os -from pathlib import Path import re -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import Any, TypedDict, final from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text import voluptuous as vol -import yarl +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, @@ -29,103 +30,81 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_DESCRIPTION, - CONF_NAME, - CONF_PLATFORM, PLATFORM_FORMAT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.service import async_set_service_schema -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.network import normalize_url -from homeassistant.util.yaml import load_yaml +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util, language as language_util -from .const import DOMAIN +from .const import ( + ATTR_CACHE, + ATTR_LANGUAGE, + ATTR_MESSAGE, + ATTR_OPTIONS, + CONF_BASE_URL, + CONF_CACHE, + CONF_CACHE_DIR, + CONF_TIME_MEMORY, + DATA_TTS_MANAGER, + DEFAULT_CACHE, + DEFAULT_CACHE_DIR, + DEFAULT_TIME_MEMORY, + DOMAIN, + TtsAudioType, +) +from .helper import get_engine_instance +from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs +from .models import Voice + +__all__ = [ + "async_default_engine", + "async_get_media_source_audio", + "async_support_options", + "ATTR_AUDIO_OUTPUT", + "CONF_LANG", + "DEFAULT_CACHE_DIR", + "generate_media_source_id", + "get_base_url", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "Provider", + "TtsAudioType", + "Voice", +] _LOGGER = logging.getLogger(__name__) -TtsAudioType = tuple[str | None, bytes | None] - -ATTR_CACHE = "cache" -ATTR_LANGUAGE = "language" -ATTR_MESSAGE = "message" -ATTR_OPTIONS = "options" ATTR_PLATFORM = "platform" +ATTR_AUDIO_OUTPUT = "audio_output" +ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" +ATTR_VOICE = "voice" + +CONF_LANG = "language" BASE_URL_KEY = "tts_base_url" -CONF_BASE_URL = "base_url" -CONF_CACHE = "cache" -CONF_CACHE_DIR = "cache_dir" -CONF_LANG = "language" -CONF_SERVICE_NAME = "service_name" -CONF_TIME_MEMORY = "time_memory" - -CONF_FIELDS = "fields" - -DEFAULT_CACHE = True -DEFAULT_CACHE_DIR = "tts" -DEFAULT_TIME_MEMORY = 300 - SERVICE_CLEAR_CACHE = "clear_cache" -SERVICE_SAY = "say" -_RE_VOICE_FILE = re.compile(r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}") +_RE_LEGACY_VOICE_FILE = re.compile( + r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}" +) +_RE_VOICE_FILE = re.compile( + r"([a-f0-9]{40})_([^_]+)_([^_]+)_(tts\.[a-z0-9_]+)\.[a-z0-9]{3,4}" +) KEY_PATTERN = "{0}_{1}_{2}_{3}" - -def _deprecated_platform(value: str) -> str: - """Validate if platform is deprecated.""" - if value == "google": - raise vol.Invalid( - "google tts service has been renamed to google_translate," - " please update your configuration." - ) - return value - - -def valid_base_url(value: str) -> str: - """Validate base url, return value.""" - url = yarl.URL(cv.url(value)) - - if url.path != "/": - raise vol.Invalid("Path should be empty") - - return normalize_url(value) - - -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), - vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, - vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string, - vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All( - vol.Coerce(int), vol.Range(min=60, max=57600) - ), - vol.Optional(CONF_BASE_URL): valid_base_url, - vol.Optional(CONF_SERVICE_NAME): cv.string, - } -) -PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) - -SCHEMA_SERVICE_SAY = vol.Schema( - { - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_CACHE): cv.boolean, - vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Optional(ATTR_LANGUAGE): cv.string, - vol.Optional(ATTR_OPTIONS): dict, - } -) - SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) @@ -134,6 +113,64 @@ class TTSCache(TypedDict): filename: str voice: bytes + pending: asyncio.Task | None + + +@callback +def async_default_engine(hass: HomeAssistant) -> str | None: + """Return the domain or entity id of the default engine. + + Returns None if no engines found. + """ + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + if "cloud" in manager.providers: + return "cloud" + + entity = next(iter(component.entities), None) + + if entity is not None: + return entity.entity_id + + return next(iter(manager.providers), None) + + +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + if engine is not None: + if not component.get_entity(engine) and engine not in manager.providers: + return None + return engine + + return async_default_engine(hass) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + if (engine_instance := get_engine_instance(hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + try: + manager.process_options(engine_instance, language, options) + except HomeAssistantError: + return False + + return True async def async_get_media_source_audio( @@ -141,132 +178,84 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - manager: SpeechManager = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] return await manager.async_get_tts_audio( **media_source_id_to_kwargs(media_source_id), ) +@callback +def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: + """Return a set with the union of languages supported by tts engines.""" + languages = set() + + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + for entity in component.entities: + for language_tag in entity.supported_languages: + languages.add(language_tag) + + for tts_engine in manager.providers.values(): + for language_tag in tts_engine.supported_languages: + languages.add(language_tag) + + return languages + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" - tts = SpeechManager(hass) + websocket_api.async_register_command(hass, websocket_list_engines) + websocket_api.async_register_command(hass, websocket_list_engine_voices) + + # Legacy config options + conf = config[DOMAIN][0] if config.get(DOMAIN) else {} + use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) + time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + base_url: str | None = conf.get(CONF_BASE_URL) + if base_url is not None: + _LOGGER.warning( + "TTS base_url option is deprecated. Configure internal/external URL" + " instead" + ) + hass.data[BASE_URL_KEY] = base_url + + tts = SpeechManager(hass, use_cache, cache_dir, time_memory, base_url) try: - conf = config[DOMAIN][0] if config.get(DOMAIN, []) else {} - use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE) - cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - base_url = conf.get(CONF_BASE_URL) - if base_url is not None: - _LOGGER.warning( - "TTS base_url option is deprecated. Configure internal/external URL" - " instead" - ) - hass.data[BASE_URL_KEY] = base_url - - await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url) + await tts.async_init_cache() except (HomeAssistantError, KeyError): _LOGGER.exception("Error on cache init") return False - hass.data[DOMAIN] = tts + hass.data[DATA_TTS_MANAGER] = tts + component = hass.data[DOMAIN] = EntityComponent[TextToSpeechEntity]( + _LOGGER, DOMAIN, hass + ) + + component.register_shutdown() + hass.http.register_view(TextToSpeechView(tts)) hass.http.register_view(TextToSpeechUrlView(tts)) - # Load service descriptions from tts/services.yaml - services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + platform_setups = await async_setup_legacy(hass, config) + + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) + + component.async_register_entity_service( + "speak", + { + vol.Required(ATTR_MEDIA_PLAYER_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_CACHE, default=DEFAULT_CACHE): cv.boolean, + vol.Optional(ATTR_LANGUAGE): cv.string, + vol.Optional(ATTR_OPTIONS): dict, + }, + "async_speak", ) - async def async_setup_platform( - p_type: str, - p_config: ConfigType | None = None, - discovery_info: DiscoveryInfoType | None = None, - ) -> None: - """Set up a TTS platform.""" - if p_config is None: - p_config = {} - - platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) - if platform is None: - return - - try: - if hasattr(platform, "async_get_engine"): - provider = await platform.async_get_engine( - hass, p_config, discovery_info - ) - else: - provider = await hass.async_add_executor_job( - platform.get_engine, hass, p_config, discovery_info - ) - - if provider is None: - _LOGGER.error("Error setting up platform %s", p_type) - return - - tts.async_register_engine(p_type, provider, p_config) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform: %s", p_type) - return - - async def async_say_handle(service: ServiceCall) -> None: - """Service handle for say.""" - entity_ids = service.data[ATTR_ENTITY_ID] - - await hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: entity_ids, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - hass, - engine=p_type, - message=service.data[ATTR_MESSAGE], - language=service.data.get(ATTR_LANGUAGE), - options=service.data.get(ATTR_OPTIONS), - cache=service.data.get(ATTR_CACHE), - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=service.context, - ) - - service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") - hass.services.async_register( - DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY - ) - - # Register the service description - service_desc = { - CONF_NAME: f"Say a TTS message with {p_type}", - CONF_DESCRIPTION: ( - f"Say something using text-to-speech on a media player with {p_type}." - ), - CONF_FIELDS: services_dict[SERVICE_SAY][CONF_FIELDS], - } - async_set_service_schema(hass, DOMAIN, service_name, service_desc) - - setup_tasks = [ - asyncio.create_task(async_setup_platform(p_type, p_config)) - for p_type, p_config in config_per_platform(config, DOMAIN) - if p_type is not None - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - - async def async_platform_discovered( - platform: str, info: dict[str, Any] | None - ) -> None: - """Handle for discovered platform.""" - await async_setup_platform(platform, discovery_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - async def async_clear_cache_handle(service: ServiceCall) -> None: """Handle clear cache service call.""" await tts.async_clear_cache() @@ -281,6 +270,129 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TextToSpeechEntity] = 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[TextToSpeechEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class TextToSpeechEntity(RestoreEntity): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + @abstractmethod + def default_language(self) -> str: + """Return the default language.""" + + @property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return None + + @property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return None + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> 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 + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) + + def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" opts_hash = hashlib.blake2s(digest_size=5) @@ -294,29 +406,30 @@ def _hash_options(options: dict) -> str: class SpeechManager: """Representation of a speech store.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + use_cache: bool, + cache_dir: str, + time_memory: int, + base_url: str | None, + ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = DEFAULT_CACHE - self.cache_dir = DEFAULT_CACHE_DIR - self.time_memory = DEFAULT_TIME_MEMORY - self.base_url: str | None = None + self.use_cache = use_cache + self.cache_dir = cache_dir + self.time_memory = time_memory + self.base_url = base_url self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - async def async_init_cache( - self, use_cache: bool, cache_dir: str, time_memory: int, base_url: str | None - ) -> None: + async def async_init_cache(self) -> None: """Init config folder and load file cache.""" - self.use_cache = use_cache - self.time_memory = time_memory - self.base_url = base_url - try: self.cache_dir = await self.hass.async_add_executor_job( - _init_tts_cache_dir, self.hass, cache_dir + _init_tts_cache_dir, self.hass, self.cache_dir ) except OSError as err: raise HomeAssistantError(f"Can't init cache dir {err}") from err @@ -347,10 +460,10 @@ class SpeechManager: self.file_cache = {} @callback - def async_register_engine( + def async_register_legacy_engine( self, engine: str, provider: Provider, config: ConfigType ) -> None: - """Register a TTS provider.""" + """Register a legacy TTS engine.""" provider.hass = self.hass if provider.name is None: provider.name = engine @@ -363,25 +476,22 @@ class SpeechManager: @callback def process_options( self, - engine: str, + engine_instance: TextToSpeechEntity | Provider, language: str | None = None, options: dict | None = None, ) -> tuple[str, dict | None]: """Validate and process options.""" - if (provider := self.providers.get(engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - # Languages - language = language or provider.default_language + language = language or engine_instance.default_language if ( language is None - or provider.supported_languages is None - or language not in provider.supported_languages + or engine_instance.supported_languages is None + or language not in engine_instance.supported_languages ): - raise HomeAssistantError(f"Not supported language {language}") + raise HomeAssistantError(f"Language '{language}' not supported") # Options - if (default_options := provider.default_options) and options: + if (default_options := engine_instance.default_options) and options: merged_options = dict(default_options) merged_options.update(options) options = merged_options @@ -389,7 +499,7 @@ class SpeechManager: options = None if default_options is None else dict(default_options) if options is not None: - supported_options = provider.supported_options or [] + supported_options = engine_instance.supported_options or [] invalid_opts = [ opt_name for opt_name in options if opt_name not in supported_options ] @@ -410,7 +520,10 @@ class SpeechManager: This method is a coroutine. """ - language, options = self.process_options(engine, language, options) + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) cache_key = self._generate_cache_key(message, language, options, engine) use_cache = cache if cache is not None else self.use_cache @@ -421,10 +534,10 @@ class SpeechManager: elif use_cache and cache_key in self.file_cache: filename = self.file_cache[cache_key] self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from provider into memory + # Load speech from engine into memory else: filename = await self._async_get_tts_audio( - engine, + engine_instance, cache_key, message, use_cache, @@ -443,7 +556,10 @@ class SpeechManager: options: dict | None = None, ) -> tuple[str, bytes]: """Fetch TTS audio.""" - language, options = self.process_options(engine, language, options) + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) cache_key = self._generate_cache_key(message, language, options, engine) use_cache = cache if cache is not None else self.use_cache @@ -453,12 +569,15 @@ class SpeechManager: await self._async_file_to_mem(cache_key) else: await self._async_get_tts_audio( - engine, cache_key, message, use_cache, language, options + engine_instance, cache_key, message, use_cache, language, options ) extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - data = self.mem_cache[cache_key]["voice"] - return extension, data + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return extension, cached["voice"] @callback def _generate_cache_key( @@ -477,7 +596,7 @@ class SpeechManager: async def _async_get_tts_audio( self, - engine: str, + engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, cache: bool, @@ -488,31 +607,73 @@ class SpeechManager: This method is a coroutine. """ - provider = self.providers[engine] - extension, data = await provider.async_get_tts_audio(message, language, options) + if options is not None and ATTR_AUDIO_OUTPUT in options: + expected_extension = options[ATTR_AUDIO_OUTPUT] + else: + expected_extension = None - if data is None or extension is None: - raise HomeAssistantError(f"No TTS from {engine} for '{message}'") + async def get_tts_data() -> str: + """Handle data available.""" + if engine_instance.name is None: + raise HomeAssistantError("TTS engine name is not set.") - # Create file infos - filename = f"{cache_key}.{extension}".lower() + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine} is invalid!" - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Save to memory - if extension == "mp3": - data = self.write_tags(filename, data, provider, message, language, options) - self._async_store_to_memcache(cache_key, filename, data) + # Create file infos + filename = f"{cache_key}.{extension}".lower() - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + # Save to memory + if extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + self._async_store_to_memcache(cache_key, filename, data) + + if cache: + self.hass.async_create_task( + self._async_save_tts_audio(cache_key, filename, data) + ) + + return filename + + audio_task = self.hass.async_create_task(get_tts_data()) + + if expected_extension is None: + return await audio_task + + def handle_error(_future: asyncio.Future) -> None: + """Handle error.""" + if audio_task.exception(): + self.mem_cache.pop(cache_key, None) + + audio_task.add_done_callback(handle_error) + + filename = f"{cache_key}.{expected_extension}".lower() + self.mem_cache[cache_key] = { + "filename": filename, + "voice": b"", + "pending": audio_task, + } return filename async def _async_save_tts_audio( @@ -563,21 +724,35 @@ class SpeechManager: self, cache_key: str, filename: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = {"filename": filename, "voice": data} + self.mem_cache[cache_key] = { + "filename": filename, + "voice": data, + "pending": None, + } @callback - def async_remove_from_mem() -> None: + def async_remove_from_mem(_: datetime) -> None: """Cleanup memcache.""" self.mem_cache.pop(cache_key, None) - self.hass.loop.call_later(self.time_memory, async_remove_from_mem) + async_call_later( + self.hass, + self.time_memory, + HassJob( + async_remove_from_mem, + name="tts remove_from_mem", + cancel_on_shutdown=True, + ), + ) async def async_read_tts(self, filename: str) -> tuple[str | None, bytes]: """Read a voice file and return binary. This method is a coroutine. """ - if not (record := _RE_VOICE_FILE.match(filename.lower())): + if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( + record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) + ): raise HomeAssistantError("Wrong tts file format!") cache_key = KEY_PATTERN.format( @@ -590,13 +765,17 @@ class SpeechManager: await self._async_file_to_mem(cache_key) content, _ = mimetypes.guess_type(filename) - return content, self.mem_cache[cache_key]["voice"] + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return content, cached["voice"] @staticmethod def write_tags( filename: str, data: bytes, - provider: Provider, + engine_name: str, message: str, language: str, options: dict | None, @@ -610,7 +789,7 @@ class SpeechManager: data_bytes.name = filename data_bytes.seek(0) - album = provider.name + album = engine_name artist = language if options is not None and (voice := options.get("voice")) is not None: @@ -645,52 +824,6 @@ class SpeechManager: return data_bytes.getvalue() -class Provider: - """Represent a single TTS provider.""" - - hass: HomeAssistant | None = None - name: str | None = None - - @property - def default_language(self) -> str | None: - """Return the default language.""" - return None - - @property - def supported_languages(self) -> list[str] | None: - """Return a list of supported languages.""" - return None - - @property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return None - - @property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return None - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> 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 - ) -> TtsAudioType: - """Load tts audio file from provider. - - Return a tuple of file extension and data as bytes. - """ - if TYPE_CHECKING: - assert self.hass - return await self.hass.async_add_executor_job( - ft.partial(self.get_tts_audio, message, language, options=options) - ) - - def _init_tts_cache_dir(hass: HomeAssistant, cache_dir: str) -> str: """Init cache folder.""" if not os.path.isabs(cache_dir): @@ -707,7 +840,9 @@ def _get_cache_files(cache_dir: str) -> dict[str, str]: folder_data = os.listdir(cache_dir) for file_data in folder_data: - if record := _RE_VOICE_FILE.match(file_data): + if (record := _RE_VOICE_FILE.match(file_data)) or ( + record := _RE_LEGACY_VOICE_FILE.match(file_data) + ): key = KEY_PATTERN.format( record.group(1), record.group(2), record.group(3), record.group(4) ) @@ -732,12 +867,16 @@ class TextToSpeechUrlView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): + if ( + not data.get("engine_id") + and not data.get(ATTR_PLATFORM) + or not data.get(ATTR_MESSAGE) + ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST ) - p_type = data[ATTR_PLATFORM] + engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) @@ -745,7 +884,7 @@ class TextToSpeechUrlView(HomeAssistantView): try: path = await self.tts.async_get_url_path( - p_type, message, cache=cache, language=language, options=options + engine, message, cache=cache, language=language, options=options ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) @@ -782,3 +921,79 @@ class TextToSpeechView(HomeAssistantView): def get_base_url(hass: HomeAssistant) -> str: """Get base URL.""" return hass.data[BASE_URL_KEY] or get_url(hass) + + +@websocket_api.websocket_command( + { + "type": "tts/engine/list", + vol.Optional("country"): str, + vol.Optional("language"): str, + } +) +@callback +def websocket_list_engines( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List text to speech engines and, optionally, if they support a given language.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + country = msg.get("country") + language = msg.get("language") + providers = [] + provider_info: dict[str, Any] + + for entity in component.entities: + provider_info = { + "engine_id": entity.entity_id, + "supported_languages": entity.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, entity.supported_languages, country + ) + providers.append(provider_info) + for engine_id, provider in manager.providers.items(): + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + if language: + provider_info["supported_languages"] = language_util.matches( + language, provider.supported_languages, country + ) + providers.append(provider_info) + + connection.send_message( + websocket_api.result_message(msg["id"], {"providers": providers}) + ) + + +@websocket_api.websocket_command( + { + "type": "tts/engine/voices", + vol.Required("engine_id"): str, + vol.Required("language"): str, + } +) +@callback +def websocket_list_engine_voices( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List voices for a given language.""" + engine_id = msg["engine_id"] + language = msg["language"] + + engine_instance = get_engine_instance(hass, engine_id) + + if not engine_instance: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"tts engine {engine_id} not found", + ) + return + + voices = {"voices": engine_instance.async_get_supported_voices(language)} + + connection.send_message(websocket_api.result_message(msg["id"], voices)) diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 492e995b87f..3427b761fa6 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -1,3 +1,21 @@ """Text-to-speech constants.""" +ATTR_CACHE = "cache" +ATTR_LANGUAGE = "language" +ATTR_MESSAGE = "message" +ATTR_OPTIONS = "options" + +CONF_BASE_URL = "base_url" +CONF_CACHE = "cache" +CONF_CACHE_DIR = "cache_dir" +CONF_FIELDS = "fields" +CONF_TIME_MEMORY = "time_memory" + +DEFAULT_CACHE = True +DEFAULT_CACHE_DIR = "tts" +DEFAULT_TIME_MEMORY = 300 DOMAIN = "tts" + +DATA_TTS_MANAGER = "tts_manager" + +TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/helper.py b/homeassistant/components/tts/helper.py new file mode 100644 index 00000000000..8cbfcbd8935 --- /dev/null +++ b/homeassistant/components/tts/helper.py @@ -0,0 +1,26 @@ +"""Provide helper functions for the TTS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import EntityComponent + +from .const import DATA_TTS_MANAGER, DOMAIN + +if TYPE_CHECKING: + from . import SpeechManager, TextToSpeechEntity + from .legacy import Provider + + +def get_engine_instance( + hass: HomeAssistant, engine: str +) -> TextToSpeechEntity | Provider | None: + """Get engine instance.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + + if entity := component.get_entity(engine): + return entity + + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + return manager.providers.get(engine) diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py new file mode 100644 index 00000000000..138c0bf84c9 --- /dev/null +++ b/homeassistant/components/tts/legacy.py @@ -0,0 +1,259 @@ +"""Provide the legacy TTS service provider interface.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Coroutine, Mapping +from functools import partial +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import voluptuous as vol +import yarl + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DESCRIPTION, + CONF_NAME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_per_platform, discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_set_service_schema +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util.network import normalize_url +from homeassistant.util.yaml import load_yaml + +from .const import ( + ATTR_CACHE, + ATTR_LANGUAGE, + ATTR_MESSAGE, + ATTR_OPTIONS, + CONF_BASE_URL, + CONF_CACHE, + CONF_CACHE_DIR, + CONF_FIELDS, + CONF_TIME_MEMORY, + DATA_TTS_MANAGER, + DEFAULT_CACHE, + DEFAULT_CACHE_DIR, + DEFAULT_TIME_MEMORY, + DOMAIN, + TtsAudioType, +) +from .media_source import generate_media_source_id +from .models import Voice + +if TYPE_CHECKING: + from . import SpeechManager + +_LOGGER = logging.getLogger(__name__) + +CONF_SERVICE_NAME = "service_name" + + +def _deprecated_platform(value: str) -> str: + """Validate if platform is deprecated.""" + if value == "google": + raise vol.Invalid( + "google tts service has been renamed to google_translate," + " please update your configuration." + ) + return value + + +def _valid_base_url(value: str) -> str: + """Validate base url, return value.""" + url = yarl.URL(cv.url(value)) + + if url.path != "/": + raise vol.Invalid("Path should be empty") + + return normalize_url(value) + + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), + vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, + vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string, + vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): vol.All( + vol.Coerce(int), vol.Range(min=60, max=57600) + ), + vol.Optional(CONF_BASE_URL): _valid_base_url, + vol.Optional(CONF_SERVICE_NAME): cv.string, + } +) +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) + +SERVICE_SAY = "say" + +SCHEMA_SERVICE_SAY = vol.Schema( + { + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_CACHE): cv.boolean, + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_LANGUAGE): cv.string, + vol.Optional(ATTR_OPTIONS): dict, + } +) + + +async def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: + """Set up legacy text to speech providers.""" + tts: SpeechManager = hass.data[DATA_TTS_MANAGER] + + # Load service descriptions from tts/services.yaml + services_yaml = Path(__file__).parent / "services.yaml" + services_dict = cast( + dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + ) + + async def async_setup_platform( + p_type: str, + p_config: ConfigType | None = None, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up a TTS platform.""" + if p_config is None: + p_config = {} + + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + if platform is None: + _LOGGER.error("Unknown text to speech platform specified") + return + + try: + if hasattr(platform, "async_get_engine"): + provider = await platform.async_get_engine( + hass, p_config, discovery_info + ) + else: + provider = await hass.async_add_executor_job( + platform.get_engine, hass, p_config, discovery_info + ) + + if provider is None: + _LOGGER.error("Error setting up platform: %s", p_type) + return + + tts.async_register_legacy_engine(p_type, provider, p_config) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform: %s", p_type) + return + + async def async_say_handle(service: ServiceCall) -> None: + """Service handle for say.""" + entity_ids = service.data[ATTR_ENTITY_ID] + + await hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + hass, + engine=p_type, + message=service.data[ATTR_MESSAGE], + language=service.data.get(ATTR_LANGUAGE), + options=service.data.get(ATTR_OPTIONS), + cache=service.data.get(ATTR_CACHE), + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=service.context, + ) + + service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") + hass.services.async_register( + DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY + ) + + # Register the service description + service_desc = { + CONF_NAME: f"Say a TTS message with {p_type}", + CONF_DESCRIPTION: ( + f"Say something using text-to-speech on a media player with {p_type}." + ), + CONF_FIELDS: services_dict[SERVICE_SAY][CONF_FIELDS], + } + async_set_service_schema(hass, DOMAIN, service_name, service_desc) + + async def async_platform_discovered( + platform: str, info: dict[str, Any] | None + ) -> None: + """Handle for discovered platform.""" + await async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return [ + async_setup_platform(p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + if p_type is not None + ] + + +class Provider: + """Represent a single TTS provider.""" + + hass: HomeAssistant | None = None + name: str | None = None + + @property + def default_language(self) -> str | None: + """Return the default language.""" + return None + + @property + @abstractmethod + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + + @property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return None + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + @property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return None + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> 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 + ) -> TtsAudioType: + """Load tts audio file from provider. + + Return a tuple of file extension and data as bytes. + """ + if TYPE_CHECKING: + assert self.hass + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 75f78df1f41..741edbc4cef 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,7 +2,7 @@ "domain": "tts", "name": "Text-to-Speech (TTS)", "after_dependencies": ["media_player"], - "codeowners": ["@pvizeli"], + "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tts", "integration_type": "entity", diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11e..34dc3822e93 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -17,12 +17,14 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url -from .const import DOMAIN +from .const import DATA_TTS_MANAGER, DOMAIN +from .helper import get_engine_instance if TYPE_CHECKING: - from . import SpeechManager + from . import SpeechManager, TextToSpeechEntity async def async_get_media_source(hass: HomeAssistant) -> TTSMediaSource: @@ -40,18 +42,18 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" - manager: SpeechManager = hass.data[DOMAIN] + from . import async_resolve_engine # pylint: disable=import-outside-toplevel - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - manager.process_options(engine, language, options) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") + + engine_instance = get_engine_instance(hass, engine) + # We raise above if the engine is not resolved, so engine_instance can't be None + assert engine_instance is not None + + manager.process_options(engine_instance, language, options) params = { "message": message, } @@ -111,7 +113,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - manager: SpeechManager = self.hass.data[DOMAIN] + manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] try: url = await manager.async_get_url_path( @@ -133,12 +135,15 @@ class TTSMediaSource(MediaSource): ) -> BrowseMediaSource: """Return media.""" if item.identifier: - provider, _, params = item.identifier.partition("?") - return self._provider_item(provider, params) + engine, _, params = item.identifier.partition("?") + return self._engine_item(engine, params) # Root. List providers. - manager: SpeechManager = self.hass.data[DOMAIN] - children = [self._provider_item(provider) for provider in manager.providers] + manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] + component: EntityComponent[TextToSpeechEntity] = self.hass.data[DOMAIN] + children = [self._engine_item(engine) for engine in manager.providers] + [ + self._engine_item(entity.entity_id) for entity in component.entities + ] return BrowseMediaSource( domain=DOMAIN, identifier=None, @@ -152,14 +157,19 @@ class TTSMediaSource(MediaSource): ) @callback - def _provider_item( - self, provider_domain: str, params: str | None = None - ) -> BrowseMediaSource: + def _engine_item(self, engine: str, params: str | None = None) -> BrowseMediaSource: """Return provider item.""" - manager: SpeechManager = self.hass.data[DOMAIN] - if (provider := manager.providers.get(provider_domain)) is None: + from . import TextToSpeechEntity # pylint: disable=import-outside-toplevel + + if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise BrowseError("Unknown provider") + if isinstance(engine_instance, TextToSpeechEntity): + assert engine_instance.platform is not None + engine_domain = engine_instance.platform.domain + else: + engine_domain = engine + if params: params = f"?{params}" else: @@ -167,11 +177,11 @@ class TTSMediaSource(MediaSource): return BrowseMediaSource( domain=DOMAIN, - identifier=f"{provider_domain}{params}", + identifier=f"{engine}{params}", media_class=MediaClass.APP, media_content_type="provider", - title=provider.name, - thumbnail=f"https://brands.home-assistant.io/_/{provider_domain}/logo.png", + title=engine_instance.name, + thumbnail=f"https://brands.home-assistant.io/_/{engine_domain}/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/tts/models.py b/homeassistant/components/tts/models.py new file mode 100644 index 00000000000..1ea49b1e9ed --- /dev/null +++ b/homeassistant/components/tts/models.py @@ -0,0 +1,10 @@ +"""Text-to-speech data models.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Voice: + """A TTS voice.""" + + voice_id: str + name: str diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 041638f830f..92244fc41f9 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -7,22 +7,26 @@ from typing import Any import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_LANGUAGE, ATTR_MESSAGE, DOMAIN +from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN CONF_MEDIA_PLAYER = "media_player" CONF_TTS_SERVICE = "tts_service" +ENTITY_LEGACY_PROVIDER_GROUP = "entity_or_legacy_provider" _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_TTS_SERVICE): cv.entity_id, + vol.Exclusive(CONF_TTS_SERVICE, ENTITY_LEGACY_PROVIDER_GROUP): cv.entity_id, + vol.Exclusive(CONF_ENTITY_ID, ENTITY_LEGACY_PROVIDER_GROUP): cv.entities_domain( + DOMAIN + ), vol.Required(CONF_MEDIA_PLAYER): cv.entity_id, vol.Optional(ATTR_LANGUAGE): cv.string, } @@ -44,7 +48,12 @@ class TTSNotificationService(BaseNotificationService): def __init__(self, config: ConfigType) -> None: """Initialize the service.""" - _, self._tts_service = split_entity_id(config[CONF_TTS_SERVICE]) + self._target: str | None = None + self._tts_service: str | None = None + if entity_id := config.get(CONF_ENTITY_ID): + self._target = entity_id + else: + _, self._tts_service = split_entity_id(config[CONF_TTS_SERVICE]) self._media_player = config[CONF_MEDIA_PLAYER] self._language = config.get(ATTR_LANGUAGE) @@ -54,13 +63,21 @@ class TTSNotificationService(BaseNotificationService): data = { ATTR_MESSAGE: message, - ATTR_ENTITY_ID: self._media_player, } + service_name = "" + + if self._tts_service: + data[ATTR_ENTITY_ID] = self._media_player + service_name = self._tts_service + elif self._target: + data[ATTR_ENTITY_ID] = self._target + data[ATTR_MEDIA_PLAYER_ENTITY_ID] = self._media_player + service_name = "speak" if self._language: data[ATTR_LANGUAGE] = self._language await self.hass.services.async_call( DOMAIN, - self._tts_service, + service_name, data, ) diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 7dcbe1287cb..99e0bcca4d4 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -40,6 +40,49 @@ say: selector: object: +speak: + name: Speak + description: Speak something using text-to-speech on a media player. + target: + entity: + domain: tts + fields: + media_player_entity_id: + name: Media Player Entity + description: Name(s) of media player entities. + required: true + selector: + entity: + domain: media_player + message: + name: Message + description: Text to speak on devices. + example: "My name is hanna" + required: true + selector: + text: + cache: + name: Cache + description: Control file cache of this message. + default: true + selector: + boolean: + language: + name: Language + description: Language to use for speech generation. + example: "ru" + selector: + text: + options: + name: Options + description: + A dictionary containing platform-specific options. Optional depending on + the platform. + advanced: true + example: platform specific + selector: + object: + clear_cache: name: Clear TTS cache description: Remove all text-to-speech cache files and RAM cache. diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 149f865e776..296857e1cfa 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -54,7 +54,6 @@ CLIENT_CONNECTED_ATTRIBUTES = [ ] CLIENT_STATIC_ATTRIBUTES = [ - "hostname", "mac", "name", "oui", @@ -175,7 +174,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, - hostname_fn=lambda api, obj_id: None, + hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname, ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 473c4ed21a5..f43e3030916 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==46"], + "requirements": ["aiounifi==47"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 87c9b9f4f4f..846c6d12234 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -131,7 +131,9 @@ async def async_poe_port_control_fn( """Control poe state.""" mac, _, index = obj_id.partition("_") device = api.devices[mac] - state = "auto" if target else "off" + port = api.ports[obj_id] + on_state = "auto" if port.raw["poe_caps"] != 8 else "passthrough" + state = on_state if target else "off" await api.request(DeviceSetPoePortModeRequest.create(device, int(index), state)) diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifiprotect/discovery.py index ea3730fa3e3..885781c6557 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifiprotect/discovery.py @@ -40,7 +40,10 @@ def async_start_discovery(hass: HomeAssistant) -> None: # Do not block startup since discovery takes 31s or more _async_start_background_discovery() async_track_time_interval( - hass, _async_start_background_discovery, DISCOVERY_INTERVAL + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, ) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d229d8f71fe..afa7f2b5d4b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -1,7 +1,7 @@ { "domain": "unifiprotect", "name": "UniFi Protect", - "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], + "codeowners": ["@AngellusMortis", "@bdraco"], "config_flow": true, "dependencies": ["http", "repairs"], "dhcp": [ @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.8.1", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.8.3", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 36870bf9c37..753563023f4 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta from enum import Enum import logging from typing import Any, Final @@ -25,22 +24,15 @@ from pyunifiprotect.data import ( Sensor, Viewer, ) -import voluptuous as vol from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE +from .const import DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T @@ -99,16 +91,6 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -SERVICE_SET_DOORBELL_MESSAGE = "set_doorbell_message" - -SET_DOORBELL_LCD_MESSAGE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_DURATION, default=""): cv.string, - } -) - @dataclass class ProtectSelectEntityDescription( @@ -352,12 +334,6 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SET_DOORBELL_MESSAGE, - SET_DOORBELL_LCD_MESSAGE_SCHEMA, - "async_set_doorbell_message", - ) class ProtectSelects(ProtectDeviceEntity, SelectEntity): @@ -428,43 +404,3 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) - - async def async_set_doorbell_message(self, message: str, duration: str) -> None: - """Set LCD Message on Doorbell display.""" - - ir.async_create_issue( - self.hass, - DOMAIN, - "deprecated_service_set_doorbell_message", - breaks_in_ha_version="2023.3.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "link": ( - "https://www.home-assistant.io/integrations" - "/text#service-textset_value" - ) - }, - translation_key="deprecated_service_set_doorbell_message", - ) - - if self.entity_description.device_class != DEVICE_CLASS_LCD_MESSAGE: - raise HomeAssistantError("Not a doorbell text select entity") - - assert isinstance(self.device, Camera) - reset_at = None - timeout_msg = "" - if duration.isnumeric(): - reset_at = utcnow() + timedelta(minutes=int(duration)) - timeout_msg = f" with timeout of {duration} minute(s)" - - _LOGGER.debug( - 'Setting message for %s to "%s"%s', - self.device.display_name, - message, - timeout_msg, - ) - await self.device.set_lcd_text( - DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at - ) diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 915c51b6c0a..90a2d5167c5 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -161,8 +161,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> camera = instance.bootstrap.get_device_from_mac(doorbell_mac) assert camera is not None doorbell_ids.add(camera.id) + data_before_changed = chime.dict_with_excludes() chime.camera_ids = sorted(doorbell_ids) - await chime.save_device() + await chime.save_device(data_before_changed) def async_setup_services(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 037c10627ad..9f9031d6543 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -52,38 +52,6 @@ set_default_doorbell_text: required: true selector: text: -set_doorbell_message: - name: Set Doorbell message - description: > - Use to dynamically set the message on a Doorbell LCD screen. This service should only be used to set dynamic messages (i.e. setting the current outdoor temperature on your Doorbell). Static messages should still be set using the Select entity and can be added/removed using the add_doorbell_text/remove_doorbell_text services. - fields: - entity_id: - name: Doorbell Text - description: The Doorbell Text select entity for your Doorbell. - example: "select.front_doorbell_camera_doorbell_text" - required: true - selector: - entity: - integration: unifiprotect - domain: select - message: - name: Message to display - description: The message you would like to display on the LCD screen of your Doorbell. Must be less than 30 characters. - example: "Welcome | 09:23 | 25°C" - required: true - selector: - text: - duration: - name: Duration - description: Number of minutes to display the message for before returning to the default message. The default is to not expire. - example: 5 - selector: - number: - min: 1 - max: 120 - step: 1 - mode: slider - unit_of_measurement: minutes set_chime_paired_doorbells: name: Set Chime Paired Doorbells description: > diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 2c0b894746e..f8d578e1ca4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -24,7 +24,7 @@ }, "discovery_confirm": { "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? [%key:component::unifiprotect::config::step::user::description%]", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 17d6f679cf0..c6a18a27b42 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -101,7 +101,7 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class UsbServiceInfo(BaseServiceInfo): """Prepared info from usb entries.""" @@ -205,11 +205,17 @@ class USBDiscovery: """Set up USB Discovery.""" await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" await self._async_scan_serial() + async def async_stop(self, event: Event) -> None: + """Stop USB Discovery.""" + if self._request_debouncer: + await self._request_debouncer.async_shutdown() + async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" if not sys.platform.startswith("linux"): diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index a8a8a80c9fa..050e801f056 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -94,7 +94,6 @@ DEVICE_CLASS_MAP = { UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -323,6 +322,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -677,11 +677,6 @@ class UtilityMeterSensor(RestoreSensor): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1b..711f66ea033 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ class VasttrafikDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 554b16877c7..b2b1cb31624 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -210,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131f..418172975d8 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": ["@oischinger"], + "codeowners": [], "config_flow": true, "dhcp": [ { @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 9b63ef17a9c..e6812ed58b1 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -4,10 +4,10 @@ "codeowners": ["@raman325"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vizio", - "integration_type": "hub", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.60"], + "requirements": ["pyvizio==0.1.61"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 7be10b80b01..665e03b531a 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "VIZIO SmartCast Device", - "description": "An [%key:common::config_flow::data::access_token%] is only needed for TVs. If you are configuring a TV and do not have an [%key:common::config_flow::data::access_token%] yet, leave it blank to go through a pairing process.", + "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", @@ -20,17 +20,17 @@ }, "pairing_complete": { "title": "Pairing Complete", - "description": "Your [%key:component::vizio::config::step::user::title%] is now connected to Home Assistant." + "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." }, "pairing_complete_import": { "title": "Pairing Complete", - "description": "Your [%key:component::vizio::config::step::user::title%] is now connected to Home Assistant.\n\nYour [%key:common::config_flow::data::access_token%] is '**{access_token}**'." + "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, "error": { "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "existing_config_entry_found": "An existing [%key:component::vizio::config::step::user::title%] config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." + "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", @@ -41,7 +41,7 @@ "options": { "step": { "init": { - "title": "Update [%key:component::vizio::config::step::user::title%] Options", + "title": "Update VIZIO SmartCast Device Options", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "data": { "volume_step": "Volume Step Size", diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py deleted file mode 100644 index 2ae169a28eb..00000000000 --- a/homeassistant/components/voice_assistant/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""The Voice Assistant integration.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN -from .websocket_api import async_register_websocket_api - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = {} - async_register_websocket_api(hass) - - return True diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py deleted file mode 100644 index 86572fb459f..00000000000 --- a/homeassistant/components/voice_assistant/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Voice Assistant integration.""" -DOMAIN = "voice_assistant" -DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json deleted file mode 100644 index 644c49e9459..00000000000 --- a/homeassistant/components/voice_assistant/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "voice_assistant", - "name": "Voice Assistant", - "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation", "stt", "tts"], - "documentation": "https://www.home-assistant.io/integrations/voice_assistant", - "iot_class": "local_push", - "quality_scale": "internal" -} diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py deleted file mode 100644 index 806a603f5e5..00000000000 --- a/homeassistant/components/voice_assistant/pipeline.py +++ /dev/null @@ -1,433 +0,0 @@ -"""Classes for voice assistant pipelines.""" -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterable, Callable -from dataclasses import asdict, dataclass, field -import logging -from typing import Any - -from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation, media_source, stt -from homeassistant.components.tts.media_source import ( - generate_media_source_id as tts_generate_media_source_id, -) -from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.util.dt import utcnow - -from .const import DOMAIN - -DEFAULT_TIMEOUT = 30 # seconds - -_LOGGER = logging.getLogger(__name__) - - -@callback -def async_get_pipeline( - hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None -) -> Pipeline | None: - """Get a pipeline by id or create one for a language.""" - if pipeline_id is not None: - return hass.data[DOMAIN].get(pipeline_id) - - # Construct a pipeline for the required/configured language - language = language or hass.config.language - return Pipeline( - name=language, - language=language, - stt_engine=None, # first engine - conversation_engine=None, # first agent - tts_engine=None, # first engine - ) - - -class PipelineError(Exception): - """Base class for pipeline errors.""" - - def __init__(self, code: str, message: str) -> None: - """Set error message.""" - self.code = code - self.message = message - - super().__init__(f"Pipeline error code={code}, message={message}") - - -class SpeechToTextError(PipelineError): - """Error in speech to text portion of pipeline.""" - - -class IntentRecognitionError(PipelineError): - """Error in intent recognition portion of pipeline.""" - - -class TextToSpeechError(PipelineError): - """Error in text to speech portion of pipeline.""" - - -class PipelineEventType(StrEnum): - """Event types emitted during a pipeline run.""" - - RUN_START = "run-start" - RUN_END = "run-end" - STT_START = "stt-start" - STT_END = "stt-end" - INTENT_START = "intent-start" - INTENT_END = "intent-end" - TTS_START = "tts-start" - TTS_END = "tts-end" - ERROR = "error" - - -@dataclass -class PipelineEvent: - """Events emitted during a pipeline run.""" - - type: PipelineEventType - data: dict[str, Any] | None = None - timestamp: str = field(default_factory=lambda: utcnow().isoformat()) - - def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the event.""" - return { - "type": self.type, - "timestamp": self.timestamp, - "data": self.data or {}, - } - - -@dataclass -class Pipeline: - """A voice assistant pipeline.""" - - name: str - language: str | None - stt_engine: str | None - conversation_engine: str | None - tts_engine: str | None - - -class PipelineStage(StrEnum): - """Stages of a pipeline.""" - - STT = "stt" - INTENT = "intent" - TTS = "tts" - - -PIPELINE_STAGE_ORDER = [ - PipelineStage.STT, - PipelineStage.INTENT, - PipelineStage.TTS, -] - - -class PipelineRunValidationError(Exception): - """Error when a pipeline run is not valid.""" - - -class InvalidPipelineStagesError(PipelineRunValidationError): - """Error when given an invalid combination of start/end stages.""" - - def __init__( - self, - start_stage: PipelineStage, - end_stage: PipelineStage, - ) -> None: - """Set error message.""" - super().__init__( - f"Invalid stage combination: start={start_stage}, end={end_stage}" - ) - - -@dataclass -class PipelineRun: - """Running context for a pipeline.""" - - hass: HomeAssistant - context: Context - pipeline: Pipeline - start_stage: PipelineStage - end_stage: PipelineStage - event_callback: Callable[[PipelineEvent], None] - language: str = None # type: ignore[assignment] - runner_data: Any | None = None - - def __post_init__(self): - """Set language for pipeline.""" - self.language = self.pipeline.language or self.hass.config.language - - # stt -> intent -> tts - if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( - self.start_stage - ): - raise InvalidPipelineStagesError(self.start_stage, self.end_stage) - - def start(self): - """Emit run start event.""" - data = { - "pipeline": self.pipeline.name, - "language": self.language, - } - if self.runner_data is not None: - data["runner_data"] = self.runner_data - - self.event_callback(PipelineEvent(PipelineEventType.RUN_START, data)) - - def end(self): - """Emit run end event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_END, - ) - ) - - async def speech_to_text( - self, - metadata: stt.SpeechMetadata, - stream: AsyncIterable[bytes], - ) -> str: - """Run speech to text portion of pipeline. Returns the spoken text.""" - engine = self.pipeline.stt_engine or "default" - self.event_callback( - PipelineEvent( - PipelineEventType.STT_START, - { - "engine": engine, - "metadata": asdict(metadata), - }, - ) - ) - - try: - # Load provider - stt_provider: stt.Provider = stt.async_get_provider( - self.hass, self.pipeline.stt_engine - ) - assert stt_provider is not None - except Exception as src_error: - _LOGGER.exception("No speech to text provider for %s", engine) - raise SpeechToTextError( - code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", - ) from src_error - - if not stt_provider.check_metadata(metadata): - raise SpeechToTextError( - code="stt-provider-unsupported-metadata", - message=f"Provider {engine} does not support input speech to text metadata", - ) - - try: - # Transcribe audio stream - result = await stt_provider.async_process_audio_stream(metadata, stream) - except Exception as src_error: - _LOGGER.exception("Unexpected error during speech to text") - raise SpeechToTextError( - code="stt-stream-failed", - message="Unexpected error during speech to text", - ) from src_error - - _LOGGER.debug("speech-to-text result %s", result) - - if result.result != stt.SpeechResultState.SUCCESS: - raise SpeechToTextError( - code="stt-stream-failed", - message="Speech to text failed", - ) - - if not result.text: - raise SpeechToTextError( - code="stt-no-text-recognized", message="No text recognized" - ) - - self.event_callback( - PipelineEvent( - PipelineEventType.STT_END, - { - "stt_output": { - "text": result.text, - } - }, - ) - ) - - return result.text - - async def recognize_intent( - self, intent_input: str, conversation_id: str | None - ) -> str: - """Run intent recognition portion of pipeline. Returns text to speak.""" - self.event_callback( - PipelineEvent( - PipelineEventType.INTENT_START, - { - "engine": self.pipeline.conversation_engine or "default", - "intent_input": intent_input, - }, - ) - ) - - try: - conversation_result = await conversation.async_converse( - hass=self.hass, - text=intent_input, - conversation_id=conversation_id, - context=self.context, - language=self.language, - agent_id=self.pipeline.conversation_engine, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during intent recognition") - raise IntentRecognitionError( - code="intent-failed", - message="Unexpected error during intent recognition", - ) from src_error - - _LOGGER.debug("conversation result %s", conversation_result) - - self.event_callback( - PipelineEvent( - PipelineEventType.INTENT_END, - {"intent_output": conversation_result.as_dict()}, - ) - ) - - speech = conversation_result.response.speech.get("plain", {}).get("speech", "") - - return speech - - async def text_to_speech(self, tts_input: str) -> str: - """Run text to speech portion of pipeline. Returns URL of TTS audio.""" - self.event_callback( - PipelineEvent( - PipelineEventType.TTS_START, - { - "engine": self.pipeline.tts_engine or "default", - "tts_input": tts_input, - }, - ) - ) - - try: - # Synthesize audio and get URL - tts_media = await media_source.async_resolve_media( - self.hass, - tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.pipeline.tts_engine, - ), - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text to speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text to speech", - ) from src_error - - _LOGGER.debug("TTS result %s", tts_media) - - self.event_callback( - PipelineEvent( - PipelineEventType.TTS_END, - {"tts_output": asdict(tts_media)}, - ) - ) - - return tts_media.url - - -@dataclass -class PipelineInput: - """Input to a pipeline run.""" - - stt_metadata: stt.SpeechMetadata | None = None - """Metadata of stt input audio. Required when start_stage = stt.""" - - stt_stream: AsyncIterable[bytes] | None = None - """Input audio for stt. Required when start_stage = stt.""" - - intent_input: str | None = None - """Input for conversation agent. Required when start_stage = intent.""" - - tts_input: str | None = None - """Input for text to speech. Required when start_stage = tts.""" - - conversation_id: str | None = None - - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - async def _execute(self, run: PipelineRun): - self._validate(run.start_stage) - - # stt -> intent -> tts - run.start() - current_stage = run.start_stage - - try: - # Speech to text - intent_input = self.intent_input - if current_stage == PipelineStage.STT: - assert self.stt_metadata is not None - assert self.stt_stream is not None - intent_input = await run.speech_to_text( - self.stt_metadata, - self.stt_stream, - ) - current_stage = PipelineStage.INTENT - - if run.end_stage != PipelineStage.STT: - tts_input = self.tts_input - - if current_stage == PipelineStage.INTENT: - assert intent_input is not None - tts_input = await run.recognize_intent( - intent_input, self.conversation_id - ) - current_stage = PipelineStage.TTS - - if run.end_stage != PipelineStage.INTENT: - if current_stage == PipelineStage.TTS: - assert tts_input is not None - await run.text_to_speech(tts_input) - - except PipelineError as err: - run.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": err.code, "message": err.message}, - ) - ) - return - - run.end() - - def _validate(self, stage: PipelineStage): - """Validate pipeline input against start stage.""" - if stage == PipelineStage.STT: - if self.stt_metadata is None: - raise PipelineRunValidationError( - "stt_metadata is required for speech to text" - ) - - if self.stt_stream is None: - raise PipelineRunValidationError( - "stt_stream is required for speech to text" - ) - elif stage == PipelineStage.INTENT: - if self.intent_input is None: - raise PipelineRunValidationError( - "intent_input is required for intent recognition" - ) - elif stage == PipelineStage.TTS: - if self.tts_input is None: - raise PipelineRunValidationError( - "tts_input is required for text to speech" - ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py deleted file mode 100644 index 28cafb7a355..00000000000 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Voice Assistant Websocket API.""" -import asyncio -import audioop -from collections.abc import Callable -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant.components import stt, websocket_api -from homeassistant.core import HomeAssistant, callback - -from .pipeline import ( - DEFAULT_TIMEOUT, - PipelineError, - PipelineEvent, - PipelineEventType, - PipelineInput, - PipelineRun, - PipelineStage, - async_get_pipeline, -) - -_LOGGER = logging.getLogger(__name__) - -_VAD_ENERGY_THRESHOLD = 1000 -_VAD_SPEECH_FRAMES = 25 -_VAD_SILENCE_FRAMES = 25 - - -@callback -def async_register_websocket_api(hass: HomeAssistant) -> None: - """Register the websocket API.""" - websocket_api.async_register_command(hass, websocket_run) - - -def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: - """Compute RMS of debiased audio.""" - energy = -audioop.rms(audio_data, width) - energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) - debiased_energy = audioop.rms( - audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width - ) - - return debiased_energy - - -@websocket_api.websocket_command( - { - vol.Required("type"): "voice_assistant/run", - # pylint: disable-next=unnecessary-lambda - vol.Required("start_stage"): lambda val: PipelineStage(val), - # pylint: disable-next=unnecessary-lambda - vol.Required("end_stage"): lambda val: PipelineStage(val), - vol.Optional("input"): {"text": str}, - vol.Optional("language"): str, - vol.Optional("pipeline"): str, - vol.Optional("conversation_id"): vol.Any(str, None), - vol.Optional("timeout"): vol.Any(float, int), - } -) -@websocket_api.async_response -async def websocket_run( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Run a pipeline.""" - language = msg.get("language", hass.config.language) - - # Temporary workaround for language codes - if language == "en": - language = "en-US" - - pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline( - hass, - pipeline_id=pipeline_id, - language=language, - ) - if pipeline is None: - connection.send_error( - msg["id"], - "pipeline-not-found", - f"Pipeline not found: id={pipeline_id}, language={language}", - ) - return - - timeout = msg.get("timeout", DEFAULT_TIMEOUT) - start_stage = PipelineStage(msg["start_stage"]) - end_stage = PipelineStage(msg["end_stage"]) - handler_id: int | None = None - unregister_handler: Callable[[], None] | None = None - - # Arguments to PipelineInput - input_args: dict[str, Any] = { - "conversation_id": msg.get("conversation_id"), - } - - if start_stage == PipelineStage.STT: - # Audio pipeline that will receive audio as binary websocket messages - audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() - - async def stt_stream(): - state = None - speech_count = 0 - in_voice_command = False - - # Yield until we receive an empty chunk - while chunk := await audio_queue.get(): - chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) - is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD - - if in_voice_command: - if is_speech: - speech_count += 1 - else: - speech_count -= 1 - - if speech_count <= -_VAD_SILENCE_FRAMES: - _LOGGER.info("Voice command stopped") - break - else: - if is_speech: - speech_count += 1 - - if speech_count >= _VAD_SPEECH_FRAMES: - in_voice_command = True - _LOGGER.info("Voice command started") - - yield chunk - - def handle_binary(_hass, _connection, data: bytes): - # Forward to STT audio stream - audio_queue.put_nowait(data) - - handler_id, unregister_handler = connection.async_register_binary_handler( - handle_binary - ) - - # Audio input must be raw PCM at 16Khz with 16-bit mono samples - input_args["stt_metadata"] = stt.SpeechMetadata( - language=language, - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ) - input_args["stt_stream"] = stt_stream() - elif start_stage == PipelineStage.INTENT: - # Input to conversation agent - input_args["intent_input"] = msg["input"]["text"] - elif start_stage == PipelineStage.TTS: - # Input to text to speech system - input_args["tts_input"] = msg["input"]["text"] - - run_task = hass.async_create_task( - PipelineInput(**input_args).execute( - PipelineRun( - hass, - context=connection.context(msg), - pipeline=pipeline, - start_stage=start_stage, - end_stage=end_stage, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - runner_data={ - "stt_binary_handler_id": handler_id, - }, - ), - timeout=timeout, - ) - ) - - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel - - # Confirm subscription - connection.send_result(msg["id"]) - - try: - # Task contains a timeout - await run_task - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) - except asyncio.TimeoutError: - connection.send_event( - msg["id"], - PipelineEvent( - PipelineEventType.ERROR, - {"code": "timeout", "message": "Timeout running pipeline"}, - ), - ) - finally: - if unregister_handler is not None: - # Unregister binary handler - unregister_handler() diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py new file mode 100644 index 00000000000..f29705cf41b --- /dev/null +++ b/homeassistant/components/voip/__init__.py @@ -0,0 +1,127 @@ +"""The Voice over IP integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from voip_utils import SIP_PORT + +from homeassistant.auth.const import GROUP_ID_USER +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import CONF_SIP_PORT, DOMAIN +from .devices import VoIPDevices +from .voip import HassVoipDatagramProtocol + +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, +) +_LOGGER = logging.getLogger(__name__) +_IP_WILDCARD = "0.0.0.0" + +__all__ = [ + "DOMAIN", + "async_setup_entry", + "async_unload_entry", + "async_remove_config_entry_device", +] + + +@dataclass +class DomainData: + """Domain data.""" + + transport: asyncio.DatagramTransport + protocol: HassVoipDatagramProtocol + devices: VoIPDevices + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up VoIP integration from a config entry.""" + # Make sure there is a valid user ID for VoIP in the config entry + if ( + "user" not in entry.data + or (await hass.auth.async_get_user(entry.data["user"])) is None + ): + voip_user = await hass.auth.async_create_system_user( + "Voice over IP", group_ids=[GROUP_ID_USER] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user": voip_user.id} + ) + + sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) + devices = VoIPDevices(hass, entry) + devices.async_setup() + transport, protocol = await _create_sip_server( + hass, + lambda: HassVoipDatagramProtocol(hass, devices), + sip_port, + ) + _LOGGER.debug("Listening for VoIP calls on port %s", sip_port) + + hass.data[DOMAIN] = DomainData(transport, protocol, devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def _create_sip_server( + hass: HomeAssistant, + protocol_factory: Callable[ + [], + asyncio.DatagramProtocol, + ], + sip_port: int, +) -> tuple[asyncio.DatagramTransport, HassVoipDatagramProtocol]: + transport, protocol = await hass.loop.create_datagram_endpoint( + protocol_factory, + local_addr=(_IP_WILDCARD, sip_port), + ) + + if not isinstance(protocol, HassVoipDatagramProtocol): + raise TypeError(f"Expected HassVoipDatagramProtocol, got {protocol}") + + return transport, protocol + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload VoIP.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + _LOGGER.debug("Shutting down VoIP server") + data = hass.data.pop(DOMAIN) + data.transport.close() + await data.protocol.wait_closed() + _LOGGER.debug("VoIP server shut down successfully") + + return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove device from a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove VoIP entry.""" + if "user" in entry.data and ( + user := await hass.auth.async_get_user(entry.data["user"]) + ): + await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py new file mode 100644 index 00000000000..8eeefbd5d94 --- /dev/null +++ b/homeassistant/components/voip/binary_sensor.py @@ -0,0 +1,60 @@ +"""Binary sensor for VoIP.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP binary sensor entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoIPCallInProgress(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices]) + + +class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): + """Entity to represent voip call is in progress.""" + + entity_description = BinarySensorEntityDescription( + key="call_in_progress", + translation_key="call_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self.async_on_remove(self._device.async_listen_update(self._is_active_changed)) + + @callback + def _is_active_changed(self, device: VoIPDevice) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py new file mode 100644 index 00000000000..3af15bd2c0b --- /dev/null +++ b/homeassistant/components/voip/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for VoIP integration.""" +from __future__ import annotations + +from typing import Any + +from voip_utils import SIP_PORT +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import CONF_SIP_PORT, DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for VoIP integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form( + step_id="user", + ) + + return self.async_create_entry( + title="Voice over IP", + data=user_input, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return VoipOptionsFlowHandler(config_entry) + + +class VoipOptionsFlowHandler(config_entries.OptionsFlow): + """Handle VoIP options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SIP_PORT, + default=self.config_entry.options.get( + CONF_SIP_PORT, + SIP_PORT, + ), + ): cv.port + } + ), + ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py new file mode 100644 index 00000000000..b4ee5d8ce7a --- /dev/null +++ b/homeassistant/components/voip/const.py @@ -0,0 +1,15 @@ +"""Constants for the Voice over IP integration.""" + +DOMAIN = "voip" + +RATE = 16000 +WIDTH = 2 +CHANNELS = 1 +RTP_AUDIO_SETTINGS = { + "rate": RATE, + "width": WIDTH, + "channels": CHANNELS, + "sleep_ratio": 0.99, +} + +CONF_SIP_PORT = "sip_port" diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py new file mode 100644 index 00000000000..5da7a97ec24 --- /dev/null +++ b/homeassistant/components/voip/devices.py @@ -0,0 +1,155 @@ +"""Class to manage devices.""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field + +from voip_utils import CallInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN + + +@dataclass +class VoIPDevice: + """Class to store device.""" + + voip_id: str + device_id: str + is_active: bool = False + update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + self.is_active = active + for listener in self.update_listeners: + listener(self) + + @callback + def async_listen_update( + self, listener: Callable[[VoIPDevice], None] + ) -> Callable[[], None]: + """Listen for updates.""" + self.update_listeners.append(listener) + return lambda: self.update_listeners.remove(listener) + + @callback + def async_allow_call(self, hass: HomeAssistant) -> bool: + """Return if call is allowed.""" + ent_reg = er.async_get(hass) + + allowed_call_entity_id = ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.voip_id}-allow_call" + ) + # If 2 requests come in fast, the device registry entry has been created + # but entity might not exist yet. + if allowed_call_entity_id is None: + return False + + if state := hass.states.get(allowed_call_entity_id): + return state.state == "on" + + return False + + +class VoIPDevices: + """Class to store devices.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize VoIP devices.""" + self.hass = hass + self.config_entry = config_entry + self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] + self.devices: dict[str, VoIPDevice] = {} + + @callback + def async_setup(self) -> None: + """Set up devices.""" + for device in dr.async_entries_for_config_entry( + dr.async_get(self.hass), self.config_entry.entry_id + ): + voip_id = next( + (item[1] for item in device.identifiers if item[0] == DOMAIN), None + ) + if voip_id is None: + continue + self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + + @callback + def async_device_removed(ev: Event) -> None: + """Handle device removed.""" + removed_id = ev.data["device_id"] + self.devices = { + voip_id: voip_device + for voip_id, voip_device in self.devices.items() + if voip_device.device_id != removed_id + } + + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + async_device_removed, + callback(lambda ev: ev.data.get("action") == "remove"), + ) + ) + + @callback + def async_add_new_device_listener( + self, listener: Callable[[VoIPDevice], None] + ) -> None: + """Add a new device listener.""" + self._new_device_listeners.append(listener) + + @callback + def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice: + """Get or create a device.""" + user_agent = call_info.headers.get("user-agent", "") + user_agent_parts = user_agent.split() + if len(user_agent_parts) == 3 and user_agent_parts[0] == "Grandstream": + manuf = user_agent_parts[0] + model = user_agent_parts[1] + fw_version = user_agent_parts[2] + else: + manuf = None + model = user_agent if user_agent else None + fw_version = None + + dev_reg = dr.async_get(self.hass) + voip_id = call_info.caller_ip + voip_device = self.devices.get(voip_id) + + if voip_device is not None: + device = dev_reg.async_get(voip_device.device_id) + if device and fw_version and device.sw_version != fw_version: + dev_reg.async_update_device(device.id, sw_version=fw_version) + + return voip_device + + device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, voip_id)}, + name=voip_id, + manufacturer=manuf, + model=model, + sw_version=fw_version, + configuration_url=f"http://{call_info.caller_ip}", + ) + voip_device = self.devices[voip_id] = VoIPDevice( + voip_id=voip_id, + device_id=device.id, + ) + for listener in self._new_device_listeners: + listener(voip_device) + + return voip_device + + def __iter__(self) -> Iterator[VoIPDevice]: + """Iterate over devices.""" + return iter(self.devices.values()) diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py new file mode 100644 index 00000000000..9b3cc641a66 --- /dev/null +++ b/homeassistant/components/voip/entity.py @@ -0,0 +1,23 @@ +"""VoIP entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity + +from .const import DOMAIN +from .devices import VoIPDevice + + +class VoIPEntity(entity.Entity): + """VoIP entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: VoIPDevice) -> None: + """Initialize VoIP entity.""" + self._device = device + self._attr_unique_id = f"{device.voip_id}-{self.entity_description.key}" + self._attr_device_info = entity.DeviceInfo( + identifiers={(DOMAIN, device.voip_id)}, + ) diff --git a/homeassistant/components/voip/error.pcm b/homeassistant/components/voip/error.pcm new file mode 100644 index 00000000000..3d93cdb14db Binary files /dev/null and b/homeassistant/components/voip/error.pcm differ diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json new file mode 100644 index 00000000000..345480da363 --- /dev/null +++ b/homeassistant/components/voip/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "voip", + "name": "Voice over IP", + "codeowners": ["@balloob", "@synesthesiam"], + "config_flow": true, + "dependencies": ["assist_pipeline"], + "documentation": "https://www.home-assistant.io/integrations/voip", + "iot_class": "local_push", + "quality_scale": "internal", + "requirements": ["voip-utils==0.0.7"] +} diff --git a/homeassistant/components/voip/not_configured.pcm b/homeassistant/components/voip/not_configured.pcm new file mode 100644 index 00000000000..22b43e60197 Binary files /dev/null and b/homeassistant/components/voip/not_configured.pcm differ diff --git a/homeassistant/components/voip/problem.pcm b/homeassistant/components/voip/problem.pcm new file mode 100644 index 00000000000..887376687f7 Binary files /dev/null and b/homeassistant/components/voip/problem.pcm differ diff --git a/homeassistant/components/voip/processing.pcm b/homeassistant/components/voip/processing.pcm new file mode 100644 index 00000000000..c76c8787e5c Binary files /dev/null and b/homeassistant/components/voip/processing.pcm differ diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py new file mode 100644 index 00000000000..7383e1b886a --- /dev/null +++ b/homeassistant/components/voip/select.py @@ -0,0 +1,46 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoipPipelineSelect(hass, device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities( + [VoipPipelineSelect(hass, device) for device in domain_data.devices] + ) + + +class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): + """Pipeline selector for VoIP devices.""" + + def __init__(self, hass: HomeAssistant, device: VoIPDevice) -> None: + """Initialize a pipeline selector.""" + VoIPEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.voip_id) diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json new file mode 100644 index 00000000000..2bef9a18008 --- /dev/null +++ b/homeassistant/components/voip/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "description": "Receive Voice over IP calls to interact with Assist." + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "entity": { + "binary_sensor": { + "call_in_progress": { + "name": "Call in progress" + } + }, + "switch": { + "allow_call": { + "name": "Allow calls" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sip_port": "SIP port" + } + } + } + } +} diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py new file mode 100644 index 00000000000..f8484241fc5 --- /dev/null +++ b/homeassistant/components/voip/switch.py @@ -0,0 +1,66 @@ +"""VoIP switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import VoIPDevice +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoIPCallAllowedSwitch(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities( + [VoIPCallAllowedSwitch(device) for device in domain_data.devices] + ) + + +class VoIPCallAllowedSwitch(VoIPEntity, restore_state.RestoreEntity, SwitchEntity): + """Entity to represent voip is allowed.""" + + entity_description = SwitchEntityDescription( + key="allow_call", + translation_key="allow_call", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/voip/tone.pcm b/homeassistant/components/voip/tone.pcm new file mode 100644 index 00000000000..175e072a27b Binary files /dev/null and b/homeassistant/components/voip/tone.pcm differ diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py new file mode 100644 index 00000000000..8b96941e00a --- /dev/null +++ b/homeassistant/components/voip/voip.py @@ -0,0 +1,475 @@ +"""Voice over IP (VoIP) implementation.""" +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import AsyncIterable, MutableSequence, Sequence +from functools import partial +import logging +from pathlib import Path +import time +from typing import TYPE_CHECKING + +import async_timeout +from voip_utils import CallInfo, RtpDatagramProtocol, SdpInfo, VoipDatagramProtocol + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import ( + Pipeline, + PipelineEvent, + PipelineEventType, + async_get_pipeline, + async_pipeline_from_audio_stream, + select as pipeline_select, +) +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.const import __version__ +from homeassistant.core import Context, HomeAssistant +from homeassistant.util.ulid import ulid + +from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH + +if TYPE_CHECKING: + from .devices import VoIPDevice, VoIPDevices + +_LOGGER = logging.getLogger(__name__) + + +def make_protocol( + hass: HomeAssistant, devices: VoIPDevices, call_info: CallInfo +) -> VoipDatagramProtocol: + """Plays a pre-recorded message if pipeline is misconfigured.""" + voip_device = devices.async_get_or_create(call_info) + pipeline_id = pipeline_select.get_chosen_pipeline( + hass, + DOMAIN, + voip_device.voip_id, + ) + pipeline = async_get_pipeline(hass, pipeline_id) + if ( + (pipeline is None) + or (pipeline.stt_engine is None) + or (pipeline.tts_engine is None) + ): + # Play pre-recorded message instead of failing + return PreRecordMessageProtocol( + hass, + "problem.pcm", + opus_payload_type=call_info.opus_payload_type, + ) + + # Pipeline is properly configured + return PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(user_id=devices.config_entry.data["user"]), + opus_payload_type=call_info.opus_payload_type, + ) + + +class HassVoipDatagramProtocol(VoipDatagramProtocol): + """HA UDP server for Voice over IP (VoIP).""" + + def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None: + """Set up VoIP call handler.""" + super().__init__( + sdp_info=SdpInfo( + username="homeassistant", + id=time.monotonic_ns(), + session_name="voip_hass", + version=__version__, + ), + valid_protocol_factory=lambda call_info: make_protocol( + hass, devices, call_info + ), + invalid_protocol_factory=lambda call_info: PreRecordMessageProtocol( + hass, + "not_configured.pcm", + opus_payload_type=call_info.opus_payload_type, + ), + ) + self.hass = hass + self.devices = devices + self._closed_event = asyncio.Event() + + def is_valid_call(self, call_info: CallInfo) -> bool: + """Filter calls.""" + device = self.devices.async_get_or_create(call_info) + return device.async_allow_call(self.hass) + + def connection_lost(self, exc): + """Signal wait_closed when transport is completely closed.""" + self.hass.loop.call_soon_threadsafe(self._closed_event.set) + + async def wait_closed(self) -> None: + """Wait for connection_lost to be called.""" + await self._closed_event.wait() + + +class PipelineRtpDatagramProtocol(RtpDatagramProtocol): + """Run a voice assistant pipeline in a loop for a VoIP call.""" + + def __init__( + self, + hass: HomeAssistant, + language: str, + voip_device: VoIPDevice, + context: Context, + opus_payload_type: int, + pipeline_timeout: float = 30.0, + audio_timeout: float = 2.0, + buffered_chunks_before_speech: int = 100, + listening_tone_enabled: bool = True, + processing_tone_enabled: bool = True, + error_tone_enabled: bool = True, + tone_delay: float = 0.2, + tts_extra_timeout: float = 1.0, + ) -> None: + """Set up pipeline RTP server.""" + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) + + self.hass = hass + self.language = language + self.voip_device = voip_device + self.pipeline: Pipeline | None = None + self.pipeline_timeout = pipeline_timeout + self.audio_timeout = audio_timeout + self.buffered_chunks_before_speech = buffered_chunks_before_speech + self.listening_tone_enabled = listening_tone_enabled + self.processing_tone_enabled = processing_tone_enabled + self.error_tone_enabled = error_tone_enabled + self.tone_delay = tone_delay + self.tts_extra_timeout = tts_extra_timeout + + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._context = context + self._conversation_id: str | None = None + self._pipeline_task: asyncio.Task | None = None + self._tts_done = asyncio.Event() + self._session_id: str | None = None + self._tone_bytes: bytes | None = None + self._processing_bytes: bytes | None = None + self._error_bytes: bytes | None = None + self._pipeline_error: bool = False + + def connection_made(self, transport): + """Server is ready.""" + super().connection_made(transport) + self.voip_device.set_is_active(True) + + def connection_lost(self, exc): + """Handle connection is lost or closed.""" + super().connection_lost(exc) + self.voip_device.set_is_active(False) + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self._pipeline_task is None: + self._clear_audio_queue() + + # Run pipeline until voice command finishes, then start over + self._pipeline_task = self.hass.async_create_background_task( + self._run_pipeline(), + "voip_pipeline_run", + ) + + self._audio_queue.put_nowait(audio_bytes) + + async def _run_pipeline( + self, + ) -> None: + """Forward audio to pipeline STT and handle TTS.""" + if self._session_id is None: + self._session_id = ulid() + + # Play listening tone at the start of each cycle + if self.listening_tone_enabled: + await self._play_listening_tone() + + try: + # Wait for speech before starting pipeline + segmenter = VoiceCommandSegmenter() + chunk_buffer: deque[bytes] = deque( + maxlen=self.buffered_chunks_before_speech, + ) + speech_detected = await self._wait_for_speech( + segmenter, + chunk_buffer, + ) + if not speech_detected: + _LOGGER.debug("No speech detected") + return + + _LOGGER.debug("Starting pipeline") + self._tts_done.clear() + + async def stt_stream(): + try: + async for chunk in self._segment_audio( + segmenter, + chunk_buffer, + ): + yield chunk + + if self.processing_tone_enabled: + await self._play_processing_tone() + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Audio timeout") + self._session_id = None + self.disconnect() + finally: + self._clear_audio_queue() + + # Run pipeline with a timeout + async with async_timeout.timeout(self.pipeline_timeout): + await async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.voip_device.voip_id + ), + conversation_id=self._conversation_id, + tts_audio_output="raw", + ) + + if self._pipeline_error: + self._pipeline_error = False + if self.error_tone_enabled: + await self._play_error_tone() + else: + # Block until TTS is done speaking. + # + # This is set in _send_tts and has a timeout that's based on the + # length of the TTS audio. + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + # Expected after caller hangs up + _LOGGER.debug("Pipeline timeout") + self._session_id = None + self.disconnect() + finally: + # Allow pipeline to run again + self._pipeline_task = None + + async def _wait_for_speech( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: MutableSequence[bytes], + ): + """Buffer audio chunks until speech is detected. + + Returns True if speech was detected, False otherwise. + """ + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + while chunk: + segmenter.process(chunk) + if segmenter.in_command: + return True + + # Buffer until command starts + chunk_buffer.append(chunk) + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_queue.get() + + return False + + async def _segment_audio( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: Sequence[bytes], + ) -> AsyncIterable[bytes]: + """Yield audio chunks until voice command has finished.""" + # Buffered chunks first + for buffered_chunk in chunk_buffer: + yield buffered_chunk + + # Timeout if no audio comes in for a while. + # This means the caller hung up. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self._audio_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._audio_queue.get() + + def _clear_audio_queue(self) -> None: + while not self._audio_queue.empty(): + self._audio_queue.get_nowait() + + def _event_callback(self, event: PipelineEvent): + if not event.data: + return + + if event.type == PipelineEventType.INTENT_END: + # Capture conversation id + self._conversation_id = event.data["intent_output"]["conversation_id"] + elif event.type == PipelineEventType.TTS_END: + # Send TTS audio to caller over RTP + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + elif event.type == PipelineEventType.ERROR: + # Play error tone instead of wait for TTS + self._pipeline_error = True + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to caller via RTP.""" + try: + if self.transport is None: + return + + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) + + # Time out 1 second after TTS audio should be finished + tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) + tts_seconds = tts_samples / RATE + + async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): + # Assume TTS audio is 16Khz 16-bit mono + await self._async_send_audio(audio_bytes) + except asyncio.TimeoutError as err: + _LOGGER.warning("TTS timeout") + raise err + finally: + # Signal pipeline to restart + self._tts_done.set() + + async def _async_send_audio(self, audio_bytes: bytes, **kwargs): + """Send audio in executor.""" + await self.hass.async_add_executor_job( + partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs) + ) + + async def _play_listening_tone(self) -> None: + """Play a tone to indicate that Home Assistant is listening.""" + if self._tone_bytes is None: + # Do I/O in executor + self._tone_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "tone.pcm", + ) + + await self._async_send_audio( + self._tone_bytes, + silence_before=self.tone_delay, + ) + + async def _play_processing_tone(self) -> None: + """Play a tone to indicate that Home Assistant is processing the voice command.""" + if self._processing_bytes is None: + # Do I/O in executor + self._processing_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "processing.pcm", + ) + + await self._async_send_audio(self._processing_bytes) + + async def _play_error_tone(self) -> None: + """Play a tone to indicate a pipeline error occurred.""" + if self._error_bytes is None: + # Do I/O in executor + self._error_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "error.pcm", + ) + + await self._async_send_audio(self._error_bytes) + + def _load_pcm(self, file_name: str) -> bytes: + """Load raw audio (16Khz, 16-bit mono).""" + return (Path(__file__).parent / file_name).read_bytes() + + +class PreRecordMessageProtocol(RtpDatagramProtocol): + """Plays a pre-recorded message on a loop.""" + + def __init__( + self, + hass: HomeAssistant, + file_name: str, + opus_payload_type: int, + message_delay: float = 1.0, + loop_delay: float = 2.0, + ) -> None: + """Set up RTP server.""" + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) + self.hass = hass + self.file_name = file_name + self.message_delay = message_delay + self.loop_delay = loop_delay + self._audio_task: asyncio.Task | None = None + self._audio_bytes: bytes | None = None + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self.transport is None: + return + + if self._audio_bytes is None: + # 16Khz, 16-bit mono audio message + file_path = Path(__file__).parent / self.file_name + self._audio_bytes = file_path.read_bytes() + + if self._audio_task is None: + self._audio_task = self.hass.async_create_background_task( + self._play_message(), + "voip_not_connected", + ) + + async def _play_message(self) -> None: + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._audio_bytes, + silence_before=self.message_delay, + **RTP_AUDIO_SETTINGS, + ) + ) + + await asyncio.sleep(self.loop_delay) + + # Allow message to play again + self._audio_task = None diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index efd20e37e83..f5d643b1336 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -21,9 +21,6 @@ CONF_TEXT_TYPE = "text" # List from https://tinyurl.com/watson-tts-docs SUPPORTED_VOICES = [ - "ar-AR_OmarVoice", - "ar-MS_OmarVoice", - "cs-CZ_AlenaVoice", "de-DE_BirgitV2Voice", "de-DE_BirgitV3Voice", "de-DE_BirgitVoice", @@ -31,11 +28,8 @@ SUPPORTED_VOICES = [ "de-DE_DieterV3Voice", "de-DE_DieterVoice", "de-DE_ErikaV3Voice", - "en-AU_CraigVoice", - "en-AU_MadisonVoice", - "en-AU_SteveVoice", - "en-GB_KateV3Voice", - "en-GB_KateVoice", + "en-AU_HeidiExpressive", + "en-AU_JackExpressive", "en-GB_CharlotteV3Voice", "en-GB_JamesV3Voice", "en-GB_KateV3Voice", @@ -74,33 +68,15 @@ SUPPORTED_VOICES = [ "it-IT_FrancescaVoice", "ja-JP_EmiV3Voice", "ja-JP_EmiVoice", - "ko-KR_HyunjunVoice", - "ko-KR_SiWooVoice", - "ko-KR_YoungmiVoice", - "ko-KR_YunaVoice", - "nl-BE_AdeleVoice", - "nl-BE_BramVoice", - "nl-NL_EmmaVoice", - "nl-NL_LiamVoice", + "ko-KR_JinV3Voice", + "nl-NL_MerelV3Voice", "pt-BR_IsabelaV3Voice", "pt-BR_IsabelaVoice", - "sv-SE_IngridVoice", - "zh-CN_LiNaVoice", - "zh-CN_WangWeiVoice", - "zh-CN_ZhangJingVoice", ] DEPRECATED_VOICES = [ - "ar-AR_OmarVoice", - "ar-MS_OmarVoice", - "cs-CZ_AlenaVoice", "de-DE_BirgitVoice", "de-DE_DieterVoice", - "en-AU_CraigVoice", - "en-AU_MadisonVoice", - "en-AU_SteveVoice", - "en-GB_KateVoice", - "en-GB_KateV3Voice", "en-US_AllisonVoice", "en-US_LisaVoice", "en-US_MichaelVoice", @@ -111,19 +87,7 @@ DEPRECATED_VOICES = [ "fr-FR_ReneeVoice", "it-IT_FrancescaVoice", "ja-JP_EmiVoice", - "ko-KR_HyunjunVoice", - "ko-KR_SiWooVoice", - "ko-KR_YoungmiVoice", - "ko-KR_YunaVoice", - "nl-BE_AdeleVoice", - "nl-BE_BramVoice", - "nl-NL_EmmaVoice", - "nl-NL_LiamVoice", "pt-BR_IsabelaVoice", - "sv-SE_IngridVoice", - "zh-CN_LiNaVoice", - "zh-CN_WangWeiVoice", - "zh-CN_ZhangJingVoice", ] SUPPORTED_OUTPUT_FORMATS = [ diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b885da3f37b..a743844659c 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -7,7 +7,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( @@ -33,14 +39,41 @@ from .helpers import is_valid_config_entry OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_INCL_FILTER, default=""): cv.string, - vol.Optional(CONF_EXCL_FILTER, default=""): cv.string, - vol.Optional(CONF_REALTIME): cv.boolean, - vol.Optional(CONF_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), - vol.Optional(CONF_UNITS): vol.In(UNITS), - vol.Optional(CONF_AVOID_TOLL_ROADS): cv.boolean, - vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): cv.boolean, - vol.Optional(CONF_AVOID_FERRIES): cv.boolean, + vol.Optional(CONF_INCL_FILTER, default=""): TextSelector(), + vol.Optional(CONF_EXCL_FILTER, default=""): TextSelector(), + vol.Optional(CONF_REALTIME): BooleanSelector(), + vol.Required(CONF_VEHICLE_TYPE): SelectSelector( + SelectSelectorConfig( + options=sorted(VEHICLE_TYPES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_VEHICLE_TYPE, + ) + ), + vol.Required(CONF_UNITS): SelectSelector( + SelectSelectorConfig( + options=sorted(UNITS), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + ) + ), + vol.Optional(CONF_AVOID_TOLL_ROADS): BooleanSelector(), + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): BooleanSelector(), + vol.Optional(CONF_AVOID_FERRIES): BooleanSelector(), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ORIGIN): TextSelector(), + vol.Required(CONF_DESTINATION): TextSelector(), + vol.Required(CONF_REGION): SelectSelector( + SelectSelectorConfig( + options=sorted(REGIONS), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_REGION, + ) + ), } ) @@ -95,6 +128,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input = user_input or {} if user_input: + user_input[CONF_REGION] = user_input[CONF_REGION].upper() if await self.hass.async_add_executor_job( is_valid_config_entry, self.hass, @@ -110,18 +144,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # If we get here, it's because we couldn't connect errors["base"] = "cannot_connect" + user_input[CONF_REGION] = user_input[CONF_REGION].lower() return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): cv.string, - vol.Required(CONF_ORIGIN): cv.string, - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_REGION): vol.In(REGIONS), - } - ), + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), errors=errors, ) diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 1121519f8cd..698ba5a63b2 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -25,7 +25,7 @@ IMPERIAL_UNITS = "imperial" METRIC_UNITS = "metric" UNITS = [METRIC_UNITS, IMPERIAL_UNITS] -REGIONS = ["US", "NA", "EU", "IL", "AU"] +REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] DEFAULT_OPTIONS: dict[str, str | bool] = { diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 9ed2ba8dfee..61b93f13f17 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -35,5 +35,29 @@ } } } + }, + "selector": { + "vehicle_type": { + "options": { + "car": "Car", + "taxi": "Taxi", + "motorcycle": "Motorcycle" + } + }, + "units": { + "options": { + "metric": "Metric System", + "imperial": "Imperial System" + } + }, + "region": { + "options": { + "us": "USA", + "na": "North America", + "eu": "Europe", + "il": "Israel", + "au": "Australia" + } + } } } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index bd80f38b832..e58890a1d18 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,7 +1,7 @@ """Webhooks for Home Assistant.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from http import HTTPStatus from ipaddress import ip_address import logging @@ -9,6 +9,7 @@ import secrets from typing import TYPE_CHECKING, Any from aiohttp import StreamReader +from aiohttp.hdrs import METH_GET, METH_HEAD, METH_POST, METH_PUT from aiohttp.web import Request, Response import voluptuous as vol @@ -25,6 +26,8 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "webhook" +DEFAULT_METHODS = (METH_POST, METH_PUT) +SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT) URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" @@ -37,7 +40,8 @@ def async_register( webhook_id: str, handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], *, - local_only=False, + local_only: bool | None = False, + allowed_methods: Iterable[str] | None = None, ) -> None: """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -45,11 +49,21 @@ def async_register( if webhook_id in handlers: raise ValueError("Handler is already defined!") + if allowed_methods is None: + allowed_methods = DEFAULT_METHODS + allowed_methods = frozenset(allowed_methods) + + if not allowed_methods.issubset(SUPPORTED_METHODS): + raise ValueError( + f"Unexpected method: {allowed_methods.difference(SUPPORTED_METHODS)}" + ) + handlers[webhook_id] = { "domain": domain, "name": name, "handler": handler, "local_only": local_only, + "allowed_methods": allowed_methods, } @@ -90,16 +104,18 @@ async def async_handle_webhook( """Handle a webhook.""" handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}) + content_stream: StreamReader | MockStreamReader + if isinstance(request, MockRequest): + received_from = request.mock_source + content_stream = request.content + method_name = request.method + else: + received_from = request.remote + content_stream = request.content + method_name = request.method + # Always respond successfully to not give away if a hook exists or not. if (webhook := handlers.get(webhook_id)) is None: - content_stream: StreamReader | MockStreamReader - if isinstance(request, MockRequest): - received_from = request.mock_source - content_stream = request.content - else: - received_from = request.remote - content_stream = request.content - _LOGGER.info( "Received message for unregistered webhook %s from %s", webhook_id, @@ -111,7 +127,21 @@ async def async_handle_webhook( _LOGGER.debug("%s", content) return Response(status=HTTPStatus.OK) - if webhook["local_only"]: + if method_name not in webhook["allowed_methods"]: + if method_name == METH_HEAD: + # Allow websites to verify that the URL exists. + return Response(status=HTTPStatus.OK) + + _LOGGER.warning( + "Webhook %s only supports %s methods but %s was received from %s", + webhook_id, + ",".join(webhook["allowed_methods"]), + method_name, + received_from, + ) + return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) + + if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): if TYPE_CHECKING: assert isinstance(request, Request) assert request.remote is not None @@ -123,7 +153,17 @@ async def async_handle_webhook( if not network.is_local(remote): _LOGGER.warning("Received remote request for local webhook %s", webhook_id) - return Response(status=HTTPStatus.OK) + if webhook["local_only"]: + return Response(status=HTTPStatus.OK) + if not webhook.get("warned_about_deprecation"): + webhook["warned_about_deprecation"] = True + _LOGGER.warning( + "Deprecation warning: " + "Webhook '%s' does not provide a value for local_only. " + "This webhook will be blocked after the 2023.7.0 release. " + "Use `local_only: false` to keep this webhook operating as-is", + webhook_id, + ) try: response = await webhook["handler"](hass, webhook_id, request) @@ -157,9 +197,11 @@ class WebhookView(HomeAssistantView): hass = request.app["hass"] return await async_handle_webhook(hass, webhook_id, request) + get = _handle head = _handle post = _handle put = _handle + get = _handle @websocket_api.websocket_command( @@ -181,6 +223,7 @@ def websocket_list( "domain": info["domain"], "name": info["name"], "local_only": info["local_only"], + "allowed_methods": sorted(info["allowed_methods"]), } for webhook_id, info in handlers.items() ] @@ -192,7 +235,7 @@ def websocket_list( { vol.Required("type"): "webhook/handle", vol.Required("webhook_id"): str, - vol.Required("method"): vol.In(["GET", "POST", "PUT"]), + vol.Required("method"): vol.In(SUPPORTED_METHODS), vol.Optional("body", default=""): str, vol.Optional("headers", default={}): {str: str}, vol.Optional("query", default=""): str, diff --git a/homeassistant/components/webhook/strings.json b/homeassistant/components/webhook/strings.json new file mode 100644 index 00000000000..53b932727d0 --- /dev/null +++ b/homeassistant/components/webhook/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "trigger_missing_local_only": { + "title": "Update webhook trigger: {webhook_id}", + "description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. [Edit the automation]({edit}) \"{automation_name}\", (`{entity_id}`) and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'" + } + } +} diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index cb1a6cb4eb6..8c6051cc4a1 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from aiohttp import hdrs import voluptuous as vol @@ -9,24 +10,46 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, async_register, async_unregister +from . import ( + DEFAULT_METHODS, + DOMAIN, + SUPPORTED_METHODS, + async_register, + async_unregister, +) + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ("webhook",) +CONF_ALLOWED_METHODS = "allowed_methods" +CONF_LOCAL_ONLY = "local_only" + TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "webhook", vol.Required(CONF_WEBHOOK_ID): cv.string, + vol.Optional(CONF_ALLOWED_METHODS): vol.All( + cv.ensure_list, + [vol.All(vol.Upper, vol.In(SUPPORTED_METHODS))], + vol.Unique(), + ), + vol.Optional(CONF_LOCAL_ONLY): bool, } ) WEBHOOK_TRIGGERS = f"{DOMAIN}_triggers" -@dataclass +@dataclass(slots=True) class TriggerInstance: """Attached trigger settings.""" @@ -62,6 +85,32 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Trigger based on incoming webhooks.""" webhook_id: str = config[CONF_WEBHOOK_ID] + local_only = config.get(CONF_LOCAL_ONLY) + issue_id: str | None = None + if local_only is None: + issue_id = f"trigger_missing_local_only_{webhook_id}" + variables = trigger_info["variables"] or {} + automation_info = variables.get("this", {}) + automation_id = automation_info.get("attributes", {}).get("id") + automation_entity_id = automation_info.get("entity_id") + automation_name = trigger_info.get("name") or automation_entity_id + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger", + translation_key="trigger_missing_local_only", + translation_placeholders={ + "webhook_id": webhook_id, + "automation_name": automation_name, + "entity_id": automation_entity_id, + "edit": f"/config/automation/edit/{automation_id}", + }, + ) + allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS) job = HassJob(action) triggers: dict[str, list[TriggerInstance]] = hass.data.setdefault( @@ -75,6 +124,8 @@ async def async_attach_trigger( trigger_info["name"], webhook_id, _handle_webhook, + local_only=local_only, + allowed_methods=allowed_methods, ) triggers[webhook_id] = [] @@ -84,6 +135,8 @@ async def async_attach_trigger( @callback def unregister(): """Unregister webhook.""" + if issue_id: + async_delete_issue(hass, DOMAIN, issue_id) triggers[webhook_id].remove(trigger_instance) if not triggers[webhook_id]: async_unregister(hass, webhook_id) diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 9afffd9fb28..a148ed2be8d 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -128,15 +128,31 @@ def ws_require_user( def websocket_command( - schema: dict[vol.Marker, Any], + schema: dict[vol.Marker, Any] | vol.All, ) -> Callable[[const.WebSocketCommandHandler], const.WebSocketCommandHandler]: - """Tag a function as a websocket command.""" - command = schema["type"] + """Tag a function as a websocket command. + + The schema must be either a dictionary where the keys are voluptuous markers, or + a voluptuous.All schema where the first item is a voluptuous Mapping schema. + """ + if isinstance(schema, dict): + command = schema["type"] + else: + command = schema.validators[0].schema["type"] def decorate(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler: """Decorate ws command function.""" # pylint: disable=protected-access - func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] + if isinstance(schema, dict): + func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] + else: + extended_schema = vol.All( + schema.validators[0].extend( + messages.BASE_COMMAND_MESSAGE_SCHEMA.schema + ), + *schema.validators[1:], + ) + func._ws_schema = extended_schema # type: ignore[attr-defined] func._ws_command = command # type: ignore[attr-defined] return func diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index a70b5d70898..11a46293f29 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -140,6 +140,7 @@ class WemoDispatcher: """Initialize the WemoDispatcher.""" self._config_entry = config_entry self._added_serial_numbers: set[str] = set() + self._failed_serial_numbers: set[str] = set() self._loaded_platforms: set[Platform] = set() async def async_add_unique_device( @@ -149,7 +150,16 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - coordinator = await async_register_device(hass, self._config_entry, wemo) + try: + coordinator = await async_register_device(hass, self._config_entry, wemo) + except pywemo.PyWeMoException as err: + if wemo.serialnumber not in self._failed_serial_numbers: + self._failed_serial_numbers.add(wemo.serialnumber) + _LOGGER.error( + "Unable to add WeMo %s %s: %s", repr(wemo), wemo.host, err + ) + return + platforms = set(WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH])) platforms.add(Platform.SENSOR) for platform in platforms: @@ -178,6 +188,7 @@ class WemoDispatcher: ) self._added_serial_numbers.add(wemo.serialnumber) + self._failed_serial_numbers.discard(wemo.serialnumber) class WemoDiscovery: diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index d744bc9efcc..4b54f9746a0 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.2"] + "requirements": ["whirlpool-sixth-sense==0.18.3"] } diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index c31ab6acd0b..4a6b1dfb44a 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -51,7 +51,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: ) hass.async_create_background_task(_async_discovery(), "wiz-discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 8daef2b3518..d1ad8456bab 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1 +1,28 @@ """Sensor to indicate whether the current day is a workday.""" +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 Workday from a config entry.""" + + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Workday config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index a2e7f1e589f..9c2e453c03d 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -12,10 +12,14 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME 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 from homeassistant.util import dt @@ -32,6 +36,7 @@ from .const import ( DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, + DOMAIN, LOGGER, ) @@ -76,21 +81,44 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Workday sensor.""" - add_holidays: list[DateLike] = config[CONF_ADD_HOLIDAYS] - remove_holidays: list[str] = config[CONF_REMOVE_HOLIDAYS] - country: str = config[CONF_COUNTRY] - days_offset: int = config[CONF_OFFSET] - excludes: list[str] = config[CONF_EXCLUDES] - province: str | None = config.get(CONF_PROVINCE) - sensor_name: str = config[CONF_NAME] - workdays: list[str] = config[CONF_WORKDAYS] + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.7.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: + """Set up the Workday sensor.""" + add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] + remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] + country: str = entry.options[CONF_COUNTRY] + days_offset: int = int(entry.options[CONF_OFFSET]) + excludes: list[str] = entry.options[CONF_EXCLUDES] + province: str | None = entry.options.get(CONF_PROVINCE) + sensor_name: str = entry.options[CONF_NAME] + workdays: list[str] = entry.options[CONF_WORKDAYS] year: int = (dt.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) @@ -131,8 +159,17 @@ def setup_platform( _holiday_string = holiday_date.strftime("%Y-%m-%d") LOGGER.debug("%s %s", _holiday_string, name) - add_entities( - [IsWorkdaySensor(obj_holidays, workdays, excludes, days_offset, sensor_name)], + async_add_entities( + [ + IsWorkdaySensor( + obj_holidays, + workdays, + excludes, + days_offset, + sensor_name, + entry.entry_id, + ) + ], True, ) @@ -140,6 +177,8 @@ def setup_platform( class IsWorkdaySensor(BinarySensorEntity): """Implementation of a Workday sensor.""" + _attr_has_entity_name = True + def __init__( self, obj_holidays: HolidayBase, @@ -147,9 +186,9 @@ class IsWorkdaySensor(BinarySensorEntity): excludes: list[str], days_offset: int, name: str, + entry_id: str, ) -> None: """Initialize the Workday sensor.""" - self._attr_name = name self._obj_holidays = obj_holidays self._workdays = workdays self._excludes = excludes @@ -159,6 +198,14 @@ class IsWorkdaySensor(BinarySensorEntity): CONF_EXCLUDES: excludes, CONF_OFFSET: days_offset, } + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="python-holidays", + model=holidays.__version__, + name=name, + ) def is_include(self, day: str, now: date) -> bool: """Check if given day is in the includes list.""" diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py new file mode 100644 index 00000000000..be11b0b034d --- /dev/null +++ b/homeassistant/components/workday/config_flow.py @@ -0,0 +1,308 @@ +"""Adds config flow for Workday integration.""" +from __future__ import annotations + +from typing import Any + +import holidays +from holidays import HolidayBase +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) +from homeassistant.util import dt + +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + DOMAIN, +) + +NONE_SENTINEL = "none" + + +def add_province_to_schema( + schema: vol.Schema, + options: dict[str, Any], +) -> 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: + return schema + + province_list = [NONE_SENTINEL, *obj_holidays.subdivisions] + add_schema = { + vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + SelectSelectorConfig( + options=province_list, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + + return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) + + +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: + 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 + ) + + for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: + if dt.parse_date(remove_date) is None: + if obj_holidays.get_named(remove_date) == []: + raise RemoveDatesError("Incorrect date or name") + + +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()), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) + +DATA_SCHEMA_OPT = vol.Schema( + { + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( + NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), + vol.Optional(CONF_ADD_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) + + +class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Workday integration.""" + + VERSION = 1 + + data: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WorkdayOptionsFlowHandler: + """Get the options flow for this handler.""" + return WorkdayOptionsFlowHandler(config_entry) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + + abort_match = { + CONF_COUNTRY: config[CONF_COUNTRY], + CONF_EXCLUDES: config[CONF_EXCLUDES], + CONF_OFFSET: config[CONF_OFFSET], + CONF_WORKDAYS: config[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: config[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: config[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: config.get(CONF_PROVINCE), + } + new_config = config.copy() + new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) + + self._async_abort_entries_match(abort_match) + return await self.async_step_options(user_input=new_config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self.data = user_input + return await self.async_step_options() + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA_SETUP, + errors=errors, + ) + + async def async_step_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle remaining flow.""" + errors: dict[str, str] = {} + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_PROVINCE] = None + + try: + await self.hass.async_add_executor_job( + validate_custom_dates, combined_input + ) + except AddDatesError: + errors["add_holidays"] = "add_holiday_error" + except RemoveDatesError: + errors["remove_holidays"] = "remove_holiday_error" + except NotImplementedError: + self.async_abort(reason="incorrect_province") + + abort_match = { + CONF_COUNTRY: combined_input[CONF_COUNTRY], + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input[CONF_PROVINCE], + } + + self._async_abort_entries_match(abort_match) + if not errors: + return self.async_create_entry( + title=combined_input[CONF_NAME], + data={}, + options=combined_input, + ) + + schema = await self.hass.async_add_executor_job( + add_province_to_schema, DATA_SCHEMA_OPT, self.data + ) + 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, + ) + + +class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Workday options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Workday options.""" + errors: dict[str, str] = {} + + if user_input is not None: + combined_input: dict[str, Any] = {**self.options, **user_input} + if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_PROVINCE] = None + + try: + await self.hass.async_add_executor_job( + validate_custom_dates, combined_input + ) + except AddDatesError: + errors["add_holidays"] = "add_holiday_error" + except RemoveDatesError: + errors["remove_holidays"] = "remove_holiday_error" + else: + try: + self._async_abort_entries_match( + { + CONF_COUNTRY: self._config_entry.options[CONF_COUNTRY], + CONF_EXCLUDES: combined_input[CONF_EXCLUDES], + CONF_OFFSET: combined_input[CONF_OFFSET], + CONF_WORKDAYS: combined_input[CONF_WORKDAYS], + CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], + CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], + CONF_PROVINCE: combined_input[CONF_PROVINCE], + } + ) + except AbortFlow as err: + errors = {"base": err.reason} + else: + 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 + ) + + new_schema = self.add_suggested_values_to_schema( + schema, user_input or self.options + ) + + return self.async_show_form( + step_id="init", + data_schema=new_schema, + errors=errors, + ) + + +class AddDatesError(HomeAssistantError): + """Exception for error adding dates.""" + + +class RemoveDatesError(HomeAssistantError): + """Exception for error removing dates.""" + + +class CountryNotExist(HomeAssistantError): + """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 810e1de3934..20905fb9892 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -3,12 +3,15 @@ from __future__ import annotations import logging -from homeassistant.const import WEEKDAYS +from homeassistant.const import WEEKDAYS, Platform LOGGER = logging.getLogger(__package__) ALLOWED_DAYS = WEEKDAYS + ["holiday"] +DOMAIN = "workday" +PLATFORMS = [Platform.BINARY_SENSOR] + CONF_COUNTRY = "country" CONF_PROVINCE = "province" CONF_WORKDAYS = "workdays" diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c9299b21ce1..e018eaa588e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,6 +2,7 @@ "domain": "workday", "name": "Workday", "codeowners": ["@fabaff", "@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json new file mode 100644 index 00000000000..f34af9ff913 --- /dev/null +++ b/homeassistant/components/workday/strings.json @@ -0,0 +1,90 @@ +{ + "config": { + "abort": { + "incorrect_province": "Incorrect subdivision from yaml import" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "country": "Country" + } + }, + "options": { + "data": { + "excludes": "Excludes", + "days_offset": "Offset", + "workdays": "Workdays", + "add_holidays": "Add holidays", + "remove_holidays": "Remove Holidays", + "province": "Subdivision of country" + }, + "data_description": { + "excludes": "List of workdays to exclude", + "days_offset": "Days offset", + "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" + } + } + }, + "error": { + "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "excludes": "[%key:component::workday::config::step::options::data::excludes%]", + "days_offset": "[%key:component::workday::config::step::options::data::days_offset%]", + "workdays": "[%key:component::workday::config::step::options::data::workdays%]", + "add_holidays": "[%key:component::workday::config::step::options::data::add_holidays%]", + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]", + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "excludes": "[%key:component::workday::config::step::options::data_description::excludes%]", + "days_offset": "[%key:component::workday::config::step::options::data_description::days_offset%]", + "workdays": "[%key:component::workday::config::step::options::data_description::workdays%]", + "add_holidays": "[%key:component::workday::config::step::options::data_description::add_holidays%]", + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]", + "province": "[%key:component::workday::config::step::options::data_description::province%]" + } + } + }, + "error": { + "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Workday YAML configuration is being removed", + "description": "Configuring Workday using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Workday YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, + "selector": { + "province": { + "options": { + "none": "No subdivision" + } + }, + "days": { + "options": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday", + "holiday": "Holidays" + } + } + } +} diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py new file mode 100644 index 00000000000..8676365212a --- /dev/null +++ b/homeassistant/components/wyoming/__init__.py @@ -0,0 +1,44 @@ +"""The Wyoming integration.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .data import WyomingService + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load Wyoming.""" + service = await WyomingService.create(entry.data["host"], entry.data["port"]) + + if service is None: + raise ConfigEntryNotReady("Unable to connect") + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + + await hass.config_entries.async_forward_entry_setups( + entry, + service.platforms, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Wyoming.""" + service: WyomingService = hass.data[DOMAIN][entry.entry_id] + + unload_ok = await hass.config_entries.async_unload_platforms( + entry, + service.platforms, + ) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + + return unload_ok diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py new file mode 100644 index 00000000000..d7d5d0278e8 --- /dev/null +++ b/homeassistant/components/wyoming/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow for Wyoming integration.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .data import WyomingService + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wyoming integration.""" + + VERSION = 1 + + _hassio_discovery: HassioServiceInfo + + 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 + ) + + service = await WyomingService.create( + user_input[CONF_HOST], + user_input[CONF_PORT], + ) + + if service is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + ) + + # ASR = automated speech recognition (STT) + asr_installed = [asr for asr in service.info.asr if asr.installed] + tts_installed = [tts for tts in service.info.tts if tts.installed] + + if asr_installed: + name = asr_installed[0].name + elif tts_installed: + name = tts_installed[0].name + else: + return self.async_abort(reason="no_services") + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + """Handle Supervisor add-on discovery.""" + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured() + + self._hassio_discovery = discovery_info + self.context.update( + { + "title_placeholders": {"name": discovery_info.name}, + "configuration_url": f"homeassistant://hassio/addon/{discovery_info.slug}/info", + } + ) + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm Supervisor discovery.""" + errors: dict[str, str] = {} + + if user_input is not None: + uri = urlparse(self._hassio_discovery.config["uri"]) + if service := await WyomingService.create(uri.hostname, uri.port): + if not any( + asr for asr in service.info.asr if asr.installed + ) and not any(tts for tts in service.info.tts if tts.installed): + return self.async_abort(reason="no_services") + + return self.async_create_entry( + title=self._hassio_discovery.name, + data={CONF_HOST: uri.hostname, CONF_PORT: uri.port}, + ) + + errors = {"base": "cannot_connect"} + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery.name}, + errors=errors, + ) diff --git a/homeassistant/components/wyoming/const.py b/homeassistant/components/wyoming/const.py new file mode 100644 index 00000000000..26443cc11eb --- /dev/null +++ b/homeassistant/components/wyoming/const.py @@ -0,0 +1,7 @@ +"""Constants for the Wyoming integration.""" + +DOMAIN = "wyoming" + +SAMPLE_RATE = 16000 +SAMPLE_WIDTH = 2 +SAMPLE_CHANNELS = 1 diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py new file mode 100644 index 00000000000..3ef93810b6e --- /dev/null +++ b/homeassistant/components/wyoming/data.py @@ -0,0 +1,77 @@ +"""Base class for Wyoming providers.""" +from __future__ import annotations + +import asyncio + +import async_timeout +from wyoming.client import AsyncTcpClient +from wyoming.info import Describe, Info + +from homeassistant.const import Platform + +from .error import WyomingError + +_INFO_TIMEOUT = 1 +_INFO_RETRY_WAIT = 2 +_INFO_RETRIES = 3 + + +class WyomingService: + """Hold info for Wyoming service.""" + + def __init__(self, host: str, port: int, info: Info) -> None: + """Initialize Wyoming service.""" + self.host = host + self.port = port + self.info = info + platforms = [] + if any(asr.installed for asr in info.asr): + platforms.append(Platform.STT) + if any(tts.installed for tts in info.tts): + platforms.append(Platform.TTS) + self.platforms = platforms + + @classmethod + async def create(cls, host: str, port: int) -> WyomingService | None: + """Create a Wyoming service.""" + info = await load_wyoming_info(host, port) + if info is None: + return None + + return cls(host, port, info) + + +async def load_wyoming_info( + host: str, + port: int, + retries: int = _INFO_RETRIES, + retry_wait: float = _INFO_RETRY_WAIT, + timeout: float = _INFO_TIMEOUT, +) -> Info | None: + """Load info from Wyoming server.""" + wyoming_info: Info | None = None + + for _ in range(retries + 1): + try: + async with AsyncTcpClient(host, port) as client: + with async_timeout.timeout(timeout): + # Describe -> Info + await client.write_event(Describe().event()) + while True: + event = await client.read_event() + if event is None: + raise WyomingError( + "Connection closed unexpectedly", + ) + + if Info.is_type(event.type): + wyoming_info = Info.from_event(event) + break # while + + if wyoming_info is not None: + break # for + except (asyncio.TimeoutError, OSError, WyomingError): + # Sleep and try again + await asyncio.sleep(retry_wait) + + return wyoming_info diff --git a/homeassistant/components/wyoming/error.py b/homeassistant/components/wyoming/error.py new file mode 100644 index 00000000000..40b2e70ce69 --- /dev/null +++ b/homeassistant/components/wyoming/error.py @@ -0,0 +1,6 @@ +"""Errors for the Wyoming integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class WyomingError(HomeAssistantError): + """Base class for Wyoming errors.""" diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json new file mode 100644 index 00000000000..9ad8092bb8c --- /dev/null +++ b/homeassistant/components/wyoming/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "wyoming", + "name": "Wyoming Protocol", + "codeowners": ["@balloob", "@synesthesiam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wyoming", + "iot_class": "local_push", + "requirements": ["wyoming==0.0.1"] +} diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json new file mode 100644 index 00000000000..20d73d8dc13 --- /dev/null +++ b/homeassistant/components/wyoming/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "no_services": "No services found at endpoint" + } + } +} diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py new file mode 100644 index 00000000000..8d3f6534502 --- /dev/null +++ b/homeassistant/components/wyoming/stt.py @@ -0,0 +1,129 @@ +"""Support for Wyoming speech to text services.""" +from collections.abc import AsyncIterable +import logging + +from wyoming.asr import Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH +from .data import WyomingService +from .error import WyomingError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming speech to text.""" + service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + WyomingSttProvider(config_entry, service), + ] + ) + + +class WyomingSttProvider(stt.SpeechToTextEntity): + """Wyoming speech to text provider.""" + + def __init__( + self, + config_entry: ConfigEntry, + service: WyomingService, + ) -> None: + """Set up provider.""" + self.service = service + asr_service = service.info.asr[0] + + model_languages: set[str] = set() + for asr_model in asr_service.models: + if asr_model.installed: + model_languages.update(asr_model.languages) + + self._supported_languages = list(model_languages) + self._attr_name = asr_service.name + self._attr_unique_id = f"{config_entry.entry_id}-stt" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._supported_languages + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + try: + async with AsyncTcpClient(self.service.host, self.service.port) as client: + await client.write_event( + AudioStart( + rate=SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + ).event(), + ) + + async for audio_bytes in stream: + chunk = AudioChunk( + rate=SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + audio=audio_bytes, + ) + await client.write_event(chunk.event()) + + await client.write_event(AudioStop().event()) + + while True: + event = await client.read_event() + if event is None: + _LOGGER.debug("Connection lost") + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + + if Transcript.is_type(event.type): + transcript = Transcript.from_event(event) + text = transcript.text + break + + except (OSError, WyomingError) as err: + _LOGGER.exception("Error processing audio stream: %s", err) + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + + return stt.SpeechResult( + text, + stt.SpeechResultState.SUCCESS, + ) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py new file mode 100644 index 00000000000..f2e314dc13e --- /dev/null +++ b/homeassistant/components/wyoming/tts.py @@ -0,0 +1,155 @@ +"""Support for Wyoming text to speech services.""" +from collections import defaultdict +import io +import logging +import wave + +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .data import WyomingService +from .error import WyomingError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming speech to text.""" + service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + WyomingTtsProvider(config_entry, service), + ] + ) + + +class WyomingTtsProvider(tts.TextToSpeechEntity): + """Wyoming text to speech provider.""" + + def __init__( + self, + config_entry: ConfigEntry, + service: WyomingService, + ) -> None: + """Set up provider.""" + self.service = service + self._tts_service = next(tts for tts in service.info.tts if tts.installed) + + voice_languages: set[str] = set() + self._voices: dict[str, list[tts.Voice]] = defaultdict(list) + for voice in self._tts_service.voices: + if not voice.installed: + continue + + voice_languages.update(voice.languages) + for language in voice.languages: + self._voices[language].append( + tts.Voice( + voice_id=voice.name, + name=voice.name, + ) + ) + + self._supported_languages: list[str] = list(voice_languages) + + self._attr_name = self._tts_service.name + self._attr_unique_id = f"{config_entry.entry_id}-tts" + + @property + def default_language(self): + """Return default language.""" + if not self._supported_languages: + return None + + return self._supported_languages[0] + + @property + def supported_languages(self): + """Return list of supported languages.""" + return self._supported_languages + + @property + def supported_options(self): + """Return list of supported options like voice, emotion.""" + return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE] + + @property + def default_options(self): + """Return a dict include default options.""" + return {tts.ATTR_AUDIO_OUTPUT: "wav"} + + @callback + def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None: + """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): + """Load TTS from UNIX socket.""" + try: + async with AsyncTcpClient(self.service.host, self.service.port) as client: + await client.write_event(Synthesize(message).event()) + + with io.BytesIO() as wav_io: + wav_writer: wave.Wave_write | None = None + while True: + event = await client.read_event() + if event is None: + _LOGGER.debug("Connection lost") + return (None, None) + + if AudioStop.is_type(event.type): + break + + if AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + if wav_writer is None: + wav_writer = wave.open(wav_io, "wb") + wav_writer.setframerate(chunk.rate) + wav_writer.setsampwidth(chunk.width) + wav_writer.setnchannels(chunk.channels) + + wav_writer.writeframes(chunk.audio) + + if wav_writer is not None: + wav_writer.close() + + data = wav_io.getvalue() + + except (OSError, WyomingError): + return (None, None) + + if (options is None) or (options[tts.ATTR_AUDIO_OUTPUT] == "wav"): + return ("wav", data) + + # Raw output (convert to 16Khz, 16-bit mono) + with io.BytesIO(data) as wav_io: + wav_reader: wave.Wave_read = wave.open(wav_io, "rb") + raw_data = ( + AudioChunkConverter( + rate=16000, + width=2, + channels=1, + ) + .convert( + AudioChunk( + audio=wav_reader.readframes(wav_reader.getnframes()), + rate=wav_reader.getframerate(), + width=wav_reader.getsampwidth(), + channels=wav_reader.getnchannels(), + ) + ) + .audio + ) + + return ("raw", raw_data) diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac3518..00000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da05..00000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index d95031a646e..00000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - -ICON = "mdi:microsoft-xbox" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd7..00000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 66ad4d01354..63fb48542c9 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -6,7 +6,7 @@ "description": "If the IP and MAC addresses are left empty, auto-discovery is used", "data": { "interface": "The network interface to use", - "host": "[%key:common::config_flow::data::ip%] (optional)", + "host": "IP address (optional)", "mac": "Mac Address (optional)" } }, @@ -29,7 +29,7 @@ "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid gateway key", - "invalid_host": "[%key:common::config_flow::error::invalid_host%], see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_mac": "Invalid Mac Address" }, "abort": { diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index d78d2f72e36..4d5cddd9517 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.16.4"] + "requirements": ["xiaomi-ble==0.17.0"] } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 58b85bc34ec..b6810cf4cf2 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -291,6 +291,7 @@ async def async_create_miio_device_and_coordinator( name = entry.title device: MiioDevice | None = None migrate = False + lazy_discover = False update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator @@ -307,38 +308,41 @@ async def async_create_miio_device_and_coordinator( # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: - device = AirHumidifierMiot(host, token) + device = AirHumidifierMiot(host, token, lazy_discover=lazy_discover) migrate = True elif model in MODELS_HUMIDIFIER_MJJSQ: - device = AirHumidifierMjjsq(host, token, model=model) + device = AirHumidifierMjjsq( + host, token, lazy_discover=lazy_discover, model=model + ) migrate = True elif model in MODELS_HUMIDIFIER_MIIO: - device = AirHumidifier(host, token, model=model) + device = AirHumidifier(host, token, lazy_discover=lazy_discover, model=model) migrate = True # Airpurifiers and Airfresh elif model in MODELS_PURIFIER_MIOT: - device = AirPurifierMiot(host, token) + device = AirPurifierMiot(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airpurifier."): - device = AirPurifier(host, token) + device = AirPurifier(host, token, lazy_discover=lazy_discover) elif model.startswith("zhimi.airfresh."): - device = AirFresh(host, token) + device = AirFresh(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_A1: - device = AirFreshA1(host, token) + device = AirFreshA1(host, token, lazy_discover=lazy_discover) elif model == MODEL_AIRFRESH_T2017: - device = AirFreshT2017(host, token) + device = AirFreshT2017(host, token, lazy_discover=lazy_discover) elif ( model in MODELS_VACUUM or model.startswith(ROBOROCK_GENERIC) or model.startswith(ROCKROBO_GENERIC) ): + # TODO: add lazy_discover as argument when python-miio add support # pylint: disable=fixme device = RoborockVacuum(host, token) update_method = _async_update_data_vacuum coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] # Pedestal fans elif model in MODEL_TO_CLASS_MAP: - device = MODEL_TO_CLASS_MAP[model](host, token) + device = MODEL_TO_CLASS_MAP[model](host, token, lazy_discover=lazy_discover) elif model in MODELS_FAN_MIIO: - device = Fan(host, token, model=model) + device = Fan(host, token, lazy_discover=lazy_discover, model=model) else: _LOGGER.error( ( diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index c343fe9a5f1..dfcb503182c 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -42,7 +42,7 @@ "host": "[%key:common::config_flow::data::ip%]", "token": "[%key:common::config_flow::data::api_token%]" }, - "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration." + "description": "You will need the 32 character API token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this API token is different from the key used by the Xiaomi Aqara integration." }, "connect": { "data": { diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index f1ec6ba14c4..381229edead 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.14"] + "requirements": ["yalexs-ble==2.1.16"] } diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815c..1fbae6c88a6 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ class DiscoverYandexTransport(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index f6d2d7fde78..8eb2991c9dc 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -16,7 +16,7 @@ from typing_extensions import Self from homeassistant import config_entries from homeassistant.components import network, ssdp -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -180,7 +180,9 @@ class YeelightScanner: # Delay starting the flow in case the discovery is the result # of another discovery - async_call_later(self._hass, 1, _async_start_flow) + async_call_later( + self._hass, 1, HassJob(_async_start_flow, cancel_on_shutdown=True) + ) @callback def _async_process_entry(self, headers: CaseInsensitiveDict) -> None: diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 28c2b799f4c..17fb4c5856d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -97,7 +97,7 @@ CONFIG_SCHEMA = vol.Schema( ) -@dataclass +@dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): """Prepared info from mDNS entries.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b967954849c..314a0c9ef78 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.56.0"] + "requirements": ["zeroconf==0.58.2"] } diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186d..9b520c46819 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Zestimate" NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 6a5e8bb476a..b6794e909d8 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -25,13 +25,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.channels.security import ( +from .core.cluster_handlers.security import ( SIGNAL_ALARM_TRIGGERED, SIGNAL_ARMED_STATE_CHANGED, - IasAce as AceChannel, + IasAce as AceClusterHandler, ) from .core.const import ( - CHANNEL_IAS_ACE, + CLUSTER_HANDLER_IAS_ACE, CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, @@ -77,10 +77,11 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_IAS_ACE) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE) class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" + _attr_name: str = "Alarm control panel" _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME @@ -89,18 +90,20 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs) -> None: + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: """Initialize the ZHA alarm control device.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) cfg_entry = zha_device.gateway.config_entry - self._channel: AceChannel = channels[0] - self._channel.panel_code = async_get_zha_config_value( + self._cluster_handler: AceClusterHandler = cluster_handlers[0] + self._cluster_handler.panel_code = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" ) - self._channel.code_required_arm_actions = async_get_zha_config_value( + self._cluster_handler.code_required_arm_actions = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False ) - self._channel.max_invalid_tries = async_get_zha_config_value( + self._cluster_handler.max_invalid_tries = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 ) @@ -108,10 +111,10 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode + self._cluster_handler, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode ) self.async_accept_signal( - self._channel, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger + self._cluster_handler, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger ) @callback @@ -122,26 +125,26 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): @property def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - return self._channel.code_required_arm_actions + return self._cluster_handler.code_required_arm_actions async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - self._channel.arm(IasAce.ArmMode.Disarm, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0) self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - self._channel.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - self._channel.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) self.async_write_ha_state() async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - self._channel.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) + self._cluster_handler.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) self.async_write_ha_state() async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -151,4 +154,4 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): @property def state(self) -> str | None: """Return the state of the entity.""" - return IAS_ACE_STATE_MAP.get(self._channel.armed_state) + return IAS_ACE_STATE_MAP.get(self._cluster_handler.armed_state) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 652f19d24ba..3d44103e225 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -2,10 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from zigpy.backups import NetworkBackup from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.types import Channels +from zigpy.util import pick_optimal_channel from .core.const import ( CONF_RADIO_TYPE, @@ -111,3 +113,22 @@ def async_get_radio_path( config_entry = _get_config_entry(hass) return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + +async def async_change_channel( + hass: HomeAssistant, new_channel: int | Literal["auto"] +) -> None: + """Migrate the ZHA network to a new channel.""" + + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + + if new_channel == "auto": + channel_energy = await app.energy_scan( + channels=Channels.ALL_CHANNELS, + duration_exp=4, + count=1, + ) + new_channel = pick_optimal_channel(channel_energy) + + await app.move_network_to_channel(new_channel) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 696216e3e81..1c29f619719 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -20,11 +20,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_ACCELEROMETER, - CHANNEL_BINARY_INPUT, - CHANNEL_OCCUPANCY, - CHANNEL_ON_OFF, - CHANNEL_ZONE, + CLUSTER_HANDLER_ACCELEROMETER, + CLUSTER_HANDLER_BINARY_INPUT, + CLUSTER_HANDLER_HUE_OCCUPANCY, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_ZONE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -33,13 +34,22 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity # Zigbee Cluster Library Zone Type to Home Assistant device class -CLASS_MAPPING = { - 0x000D: BinarySensorDeviceClass.MOTION, - 0x0015: BinarySensorDeviceClass.OPENING, - 0x0028: BinarySensorDeviceClass.SMOKE, - 0x002A: BinarySensorDeviceClass.MOISTURE, - 0x002B: BinarySensorDeviceClass.GAS, - 0x002D: BinarySensorDeviceClass.VIBRATION, +IAS_ZONE_CLASS_MAPPING = { + IasZone.ZoneType.Motion_Sensor: BinarySensorDeviceClass.MOTION, + IasZone.ZoneType.Contact_Switch: BinarySensorDeviceClass.OPENING, + IasZone.ZoneType.Fire_Sensor: BinarySensorDeviceClass.SMOKE, + IasZone.ZoneType.Water_Sensor: BinarySensorDeviceClass.MOISTURE, + IasZone.ZoneType.Carbon_Monoxide_Sensor: BinarySensorDeviceClass.GAS, + IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, +} + +IAS_ZONE_NAME_MAPPING = { + IasZone.ZoneType.Motion_Sensor: "Motion", + IasZone.ZoneType.Contact_Switch: "Opening", + IasZone.ZoneType.Fire_Sensor: "Smoke", + IasZone.ZoneType.Water_Sensor: "Moisture", + IasZone.ZoneType.Carbon_Monoxide_Sensor: "Gas", + IasZone.ZoneType.Vibration_Movement_Sensor: "Vibration", } STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) @@ -72,22 +82,22 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): SENSOR_ATTR: str | None = None - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA binary sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler = cluster_handlers[0] async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) if raw_state is None: return False return self.parse(raw_state) @@ -103,27 +113,35 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): return bool(value) -@MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ACCELEROMETER) class Accelerometer(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "acceleration" + _attr_name: str = "Accelerometer" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING -@MULTI_MATCH(channel_names=CHANNEL_OCCUPANCY) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY) class Occupancy(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "occupancy" + _attr_name: str = "Occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY) +class HueOccupancy(Occupancy): + """ZHA Hue occupancy.""" + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): """ZHA OnOff BinarySensor.""" SENSOR_ATTR = "on_off" + _attr_name: str = "Opening" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. @@ -131,47 +149,56 @@ class Opening(BinarySensor): @callback def async_restore_last_state(self, last_state): """Restore previous state to zigpy cache.""" - self._channel.cluster.update_attribute( + self._cluster_handler.cluster.update_attribute( OnOff.attributes_by_name[self.SENSOR_ATTR].id, t.Bool.true if last_state.state == STATE_ON else t.Bool.false, ) -@MULTI_MATCH(channel_names=CHANNEL_BINARY_INPUT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_INPUT) class BinaryInput(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "present_value" + _attr_name: str = "Binary input" @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, manufacturers="IKEA of Sweden", models=lambda model: isinstance(model, str) and model is not None and model.find("motion") != -1, ) @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, manufacturers="Philips", models={"SML001", "SML002"}, ) class Motion(Opening): """ZHA OnOff BinarySensor with motion device class.""" + _attr_name: str = "Motion" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION -@MULTI_MATCH(channel_names=CHANNEL_ZONE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE) class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" SENSOR_ATTR = "zone_status" + @property + def name(self) -> str | None: + """Return the name of the sensor.""" + zone_type = self._cluster_handler.cluster.get("zone_type") + return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" - return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) + zone_type = self._cluster_handler.cluster.get("zone_type") + return IAS_ZONE_CLASS_MAPPING.get(zone_type) @staticmethod def parse(value: bool | int) -> bool: @@ -204,13 +231,13 @@ class IASZone(BinarySensor): else: migrated_state = IasZone.ZoneStatus(0) - self._channel.cluster.update_attribute( + self._cluster_handler.cluster.update_attribute( IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state ) @MULTI_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -220,17 +247,19 @@ class FrostLock(BinarySensor, id_suffix="frost_lock"): SENSOR_ATTR = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + _attr_name: str = "Frost lock" -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): """ZHA BinarySensor.""" SENSOR_ATTR = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Replace filter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): """ZHA aqara pet feeder error detected binary sensor.""" @@ -240,7 +269,8 @@ class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): @MULTI_MATCH( - channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): """ZHA Xiaomi plug consumer connected binary sensor.""" @@ -250,7 +280,7 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG -@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): """ZHA Aqara thermostat window open binary sensor.""" @@ -259,7 +289,7 @@ class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): _attr_name: str = "Window open" -@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): """ZHA Aqara thermostat valve alarm binary sensor.""" @@ -268,7 +298,9 @@ class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): _attr_name: str = "Valve alarm" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): """ZHA Aqara thermostat calibrated binary sensor.""" @@ -277,7 +309,9 @@ class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): _attr_name: str = "Calibrated" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): """ZHA Aqara thermostat external sensor binary sensor.""" @@ -286,7 +320,7 @@ class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): _attr_name: str = "External sensor" -@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): """ZHA Aqara linkage alarm state binary sensor.""" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index b3ff3f5aedd..6564f3bc39a 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -18,12 +18,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CHANNEL_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice @@ -65,12 +65,12 @@ class ZHAButton(ZhaEntity, ButtonEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] @abc.abstractmethod def get_args(self) -> list[Any]: @@ -78,12 +78,12 @@ class ZHAButton(ZhaEntity, ButtonEntity): async def async_press(self) -> None: """Send out a update command.""" - command = getattr(self._channel, self._command_name) + command = getattr(self._cluster_handler, self._command_name) arguments = self.get_args() await command(*arguments) -@MULTI_MATCH(channel_names=CHANNEL_IDENTIFY) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) class ZHAIdentifyButton(ZHAButton): """Defines a ZHA identify button.""" @@ -92,7 +92,7 @@ class ZHAIdentifyButton(ZHAButton): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -100,10 +100,10 @@ class ZHAIdentifyButton(ZHAButton): Return entity if it is a supported configuration, otherwise return None """ if ZHA_ENTITIES.prevent_entity_creation( - Platform.BUTTON, zha_device.ieee, CHANNEL_IDENTIFY + Platform.BUTTON, zha_device.ieee, CLUSTER_HANDLER_IDENTIFY ): return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) _attr_device_class: ButtonDeviceClass = ButtonDeviceClass.UPDATE _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -126,17 +126,17 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] async def async_press(self) -> None: """Write attribute with defined value.""" try: - result = await self._channel.cluster.write_attributes( + result = await self._cluster_handler.cluster.write_attributes( {self._attribute_name: self._attribute_value} ) except zigpy.exceptions.ZigbeeException as ex: @@ -149,7 +149,7 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -164,7 +164,9 @@ class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): _attr_entity_category = EntityCategory.CONFIG -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class NoPresenceStatusResetButton( ZHAAttributeButton, id_suffix="reset_no_presence_status" ): @@ -177,7 +179,7 @@ class NoPresenceStatusResetButton( _attr_entity_category = EntityCategory.CONFIG -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): """Defines a feed button for the aqara c1 pet feeder.""" @@ -187,7 +189,7 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): """Defines a ZHA self-test button for Aqara smoke sensors.""" diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 022be309c45..9f999bd52fa 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -43,8 +43,8 @@ import homeassistant.util.dt as dt_util from .core import discovery from .core.const import ( - CHANNEL_FAN, - CHANNEL_THERMOSTAT, + CLUSTER_HANDLER_FAN, + CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, @@ -127,9 +127,9 @@ async def async_setup_entry( @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, - stop_on_match_group=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class Thermostat(ZhaEntity, ClimateEntity): """Representation of a ZHA Thermostat device.""" @@ -139,15 +139,16 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_name: str = "Thermostat" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._thrm = self.cluster_channels.get(CHANNEL_THERMOSTAT) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) self._preset = PRESET_NONE self._presets = [] self._supported_flags = ClimateEntityFeature.TARGET_TEMPERATURE - self._fan = self.cluster_channels.get(CHANNEL_FAN) + self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) @property def current_temperature(self): @@ -480,9 +481,9 @@ class Thermostat(ZhaEntity, ClimateEntity): @MULTI_MATCH( - channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"}, + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT, "sinope_manufacturer_specific"}, manufacturers="Sinope Technologies", - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class SinopeTechnologiesThermostat(Thermostat): """Sinope Technologies Thermostat.""" @@ -490,12 +491,12 @@ class SinopeTechnologiesThermostat(Thermostat): manufacturer = 0x119C update_time_interval = timedelta(minutes=randint(45, 75)) - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [PRESET_AWAY, PRESET_NONE] self._supported_flags |= ClimateEntityFeature.PRESET_MODE - self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] + self._manufacturer_ch = self.cluster_handlers["sinope_manufacturer_specific"] @property def _rm_rs_action(self) -> HVACAction: @@ -536,8 +537,10 @@ class SinopeTechnologiesThermostat(Thermostat): async def async_added_to_hass(self) -> None: """Run when about to be added to Hass.""" await super().async_added_to_hass() - async_track_time_interval( - self.hass, self._async_update_time, self.update_time_interval + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_update_time, self.update_time_interval + ) ) self._async_update_time() @@ -553,28 +556,28 @@ class SinopeTechnologiesThermostat(Thermostat): @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, manufacturers={"Zen Within", "LUX"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class ZenWithinThermostat(Thermostat): """Zen Within Thermostat implementation.""" @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, manufacturers="Centralite", models={"3157100", "3157100-E"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class CentralitePearl(ZenWithinThermostat): """Centralite Pearl Thermostat implementation.""" @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_ckud7u2l", "_TZE200_ywdxldoj", @@ -594,9 +597,9 @@ class CentralitePearl(ZenWithinThermostat): class MoesThermostat(Thermostat): """Moes Thermostat implementation.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, PRESET_AWAY, @@ -668,7 +671,7 @@ class MoesThermostat(Thermostat): @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_b6wax7g0", }, @@ -676,9 +679,9 @@ class MoesThermostat(Thermostat): class BecaThermostat(Thermostat): """Beca Thermostat implementation.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, PRESET_AWAY, @@ -743,10 +746,10 @@ class BecaThermostat(Thermostat): @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers="Stelpro", models={"SORB"}, - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class StelproFanHeater(Thermostat): """Stelpro Fan Heater implementation.""" @@ -758,7 +761,7 @@ class StelproFanHeater(Thermostat): @STRICT_MATCH( - channel_names=CHANNEL_THERMOSTAT, + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, manufacturers={ "_TZE200_7yoranx2", "_TZE200_e9ba97vf", # TV01-ZG @@ -780,9 +783,9 @@ class ZONNSMARTThermostat(Thermostat): PRESET_HOLIDAY = "holiday" PRESET_FROST = "frost protect" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, channels, **kwargs) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._presets = [ PRESET_NONE, self.PRESET_HOLIDAY, diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 53c4e338810..5230d77ce46 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -32,7 +32,11 @@ from .core.const import ( DOMAIN, RadioType, ) -from .radio_manager import HARDWARE_DISCOVERY_SCHEMA, ZhaRadioManager +from .radio_manager import ( + HARDWARE_DISCOVERY_SCHEMA, + RECOMMENDED_RADIOS, + ZhaRadioManager, +) CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( @@ -192,7 +196,7 @@ class BaseZhaFlow(FlowHandler): else "" ) - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() # Pre-select the currently configured port default_port = vol.UNDEFINED @@ -252,7 +256,7 @@ class BaseZhaFlow(FlowHandler): self._radio_mgr.device_settings = user_input.copy() if await self._radio_mgr.radio_type.controller.probe(user_input): - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() errors["base"] = "cannot_connect" @@ -289,6 +293,26 @@ class BaseZhaFlow(FlowHandler): errors=errors, ) + async def async_step_verify_radio( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add a warning step to dissuade the use of deprecated radios.""" + assert self._radio_mgr.radio_type is not None + + # Skip this step if we are using a recommended radio + if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: + return await self.async_step_choose_formation_strategy() + + return self.async_show_form( + step_id="verify_radio", + description_placeholders={ + CONF_NAME: self._radio_mgr.radio_type.description, + "docs_recommended_adapters_url": ( + "https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules" + ), + }, + ) + async def async_step_choose_formation_strategy( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -516,7 +540,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN if self._radio_mgr.device_settings is None: return await self.async_step_manual_port_config() - return await self.async_step_choose_formation_strategy() + return await self.async_step_verify_radio() return self.async_show_form( step_id="confirm", diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py deleted file mode 100644 index a708e65a07a..00000000000 --- a/homeassistant/components/zha/core/channels/__init__.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Channels module for Zigbee Home Automation.""" -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, Any - -from typing_extensions import Self -import zigpy.endpoint -import zigpy.zcl.clusters.closures - -from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from . import ( # noqa: F401 - base, - closures, - general, - homeautomation, - hvac, - lighting, - lightlink, - manufacturerspecific, - measurement, - protocol, - security, - smartenergy, -) -from .. import ( - const, - device as zha_core_device, - discovery as zha_disc, - registries as zha_regs, -) - -if TYPE_CHECKING: - from ...entity import ZhaEntity - from ..device import ZHADevice - -_ChannelsDictType = dict[str, base.ZigbeeChannel] - - -class Channels: - """All discovered channels of a device.""" - - def __init__(self, zha_device: ZHADevice) -> None: - """Initialize instance.""" - self._pools: list[ChannelPool] = [] - self._power_config: base.ZigbeeChannel | None = None - self._identify: base.ZigbeeChannel | None = None - self._unique_id = str(zha_device.ieee) - self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device) - self._zha_device = zha_device - - @property - def pools(self) -> list[ChannelPool]: - """Return channel pools list.""" - return self._pools - - @property - def power_configuration_ch(self) -> base.ZigbeeChannel | None: - """Return power configuration channel.""" - return self._power_config - - @power_configuration_ch.setter - def power_configuration_ch(self, channel: base.ZigbeeChannel) -> None: - """Power configuration channel setter.""" - if self._power_config is None: - self._power_config = channel - - @property - def identify_ch(self) -> base.ZigbeeChannel | None: - """Return power configuration channel.""" - return self._identify - - @identify_ch.setter - def identify_ch(self, channel: base.ZigbeeChannel) -> None: - """Power configuration channel setter.""" - if self._identify is None: - self._identify = channel - - @property - def zdo_channel(self) -> base.ZDOChannel: - """Return ZDO channel.""" - return self._zdo_channel - - @property - def zha_device(self) -> ZHADevice: - """Return parent ZHA device.""" - return self._zha_device - - @property - def unique_id(self) -> str: - """Return the unique id for this channel.""" - return self._unique_id - - @property - def zigbee_signature(self) -> dict[int, dict[str, Any]]: - """Get the zigbee signatures for the pools in channels.""" - return { - signature[0]: signature[1] - for signature in [pool.zigbee_signature for pool in self.pools] - } - - @classmethod - def new(cls, zha_device: ZHADevice) -> Self: - """Create new instance.""" - channels = cls(zha_device) - for ep_id in sorted(zha_device.device.endpoints): - channels.add_pool(ep_id) - return channels - - def add_pool(self, ep_id: int) -> None: - """Add channels for a specific endpoint.""" - if ep_id == 0: - return - self._pools.append(ChannelPool.new(self, ep_id)) - - async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize claimed channels.""" - await self.zdo_channel.async_initialize(from_cache) - self.zdo_channel.debug("'async_initialize' stage succeeded") - await asyncio.gather( - *(pool.async_initialize(from_cache) for pool in self.pools) - ) - - async def async_configure(self) -> None: - """Configure claimed channels.""" - await self.zdo_channel.async_configure() - self.zdo_channel.debug("'async_configure' stage succeeded") - await asyncio.gather(*(pool.async_configure() for pool in self.pools)) - async_dispatcher_send( - self.zha_device.hass, - const.ZHA_CHANNEL_MSG, - { - const.ATTR_TYPE: const.ZHA_CHANNEL_CFG_DONE, - }, - ) - - @callback - def async_new_entity( - self, - component: str, - entity_class: type[ZhaEntity], - unique_id: str, - channels: list[base.ZigbeeChannel], - ): - """Signal new entity addition.""" - if self.zha_device.status == zha_core_device.DeviceStatus.INITIALIZED: - return - - self.zha_device.hass.data[const.DATA_ZHA][component].append( - (entity_class, (unique_id, self.zha_device, channels)) - ) - - @callback - def async_send_signal(self, signal: str, *args: Any) -> None: - """Send a signal through hass dispatcher.""" - async_dispatcher_send(self.zha_device.hass, signal, *args) - - @callback - def zha_send_event(self, event_data: dict[str, str | int]) -> None: - """Relay events to hass.""" - self.zha_device.hass.bus.async_fire( - const.ZHA_EVENT, - { - const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), - const.ATTR_UNIQUE_ID: self.unique_id, - ATTR_DEVICE_ID: self.zha_device.device_id, - **event_data, - }, - ) - - -class ChannelPool: - """All channels of an endpoint.""" - - def __init__(self, channels: Channels, ep_id: int) -> None: - """Initialize instance.""" - self._all_channels: _ChannelsDictType = {} - self._channels = channels - self._claimed_channels: _ChannelsDictType = {} - self._id = ep_id - self._client_channels: dict[str, base.ClientChannel] = {} - self._unique_id = f"{channels.unique_id}-{ep_id}" - - @property - def all_channels(self) -> _ChannelsDictType: - """All server channels of an endpoint.""" - return self._all_channels - - @property - def claimed_channels(self) -> _ChannelsDictType: - """Channels in use.""" - return self._claimed_channels - - @property - def client_channels(self) -> dict[str, base.ClientChannel]: - """Return a dict of client channels.""" - return self._client_channels - - @property - def endpoint(self) -> zigpy.endpoint.Endpoint: - """Return endpoint of zigpy device.""" - return self._channels.zha_device.device.endpoints[self.id] - - @property - def id(self) -> int: - """Return endpoint id.""" - return self._id - - @property - def nwk(self) -> int: - """Device NWK for logging.""" - return self._channels.zha_device.nwk - - @property - def is_mains_powered(self) -> bool | None: - """Device is_mains_powered.""" - return self._channels.zha_device.is_mains_powered - - @property - def manufacturer(self) -> str: - """Return device manufacturer.""" - return self._channels.zha_device.manufacturer - - @property - def manufacturer_code(self) -> int | None: - """Return device manufacturer.""" - return self._channels.zha_device.manufacturer_code - - @property - def hass(self) -> HomeAssistant: - """Return hass.""" - return self._channels.zha_device.hass - - @property - def model(self) -> str: - """Return device model.""" - return self._channels.zha_device.model - - @property - def quirk_class(self) -> str: - """Return device quirk class.""" - return self._channels.zha_device.quirk_class - - @property - def skip_configuration(self) -> bool: - """Return True if device does not require channel configuration.""" - return self._channels.zha_device.skip_configuration - - @property - def unique_id(self) -> str: - """Return the unique id for this channel.""" - return self._unique_id - - @property - def zigbee_signature(self) -> tuple[int, dict[str, Any]]: - """Get the zigbee signature for the endpoint this pool represents.""" - return ( - self.endpoint.endpoint_id, - { - const.ATTR_PROFILE_ID: self.endpoint.profile_id, - const.ATTR_DEVICE_TYPE: f"0x{self.endpoint.device_type:04x}" - if self.endpoint.device_type is not None - else "", - const.ATTR_IN_CLUSTERS: [ - f"0x{cluster_id:04x}" - for cluster_id in sorted(self.endpoint.in_clusters) - ], - const.ATTR_OUT_CLUSTERS: [ - f"0x{cluster_id:04x}" - for cluster_id in sorted(self.endpoint.out_clusters) - ], - }, - ) - - @classmethod - def new(cls, channels: Channels, ep_id: int) -> Self: - """Create new channels for an endpoint.""" - pool = cls(channels, ep_id) - pool.add_all_channels() - pool.add_client_channels() - if not channels.zha_device.is_coordinator: - zha_disc.PROBE.discover_entities(pool) - return pool - - @callback - def add_all_channels(self) -> None: - """Create and add channels for all input clusters.""" - for cluster_id, cluster in self.endpoint.in_clusters.items(): - channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.ZigbeeChannel - ) - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if ( - hasattr(cluster, "ep_attribute") - and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id - and cluster.ep_attribute == "multistate_input" - ): - channel_class = general.MultistateInput - # end of ugly hack - channel = channel_class(cluster, self) - if channel.name == const.CHANNEL_POWER_CONFIGURATION: - if ( - self._channels.power_configuration_ch - or self._channels.zha_device.is_mains_powered - ): - # on power configuration channel per device - continue - self._channels.power_configuration_ch = channel - elif channel.name == const.CHANNEL_IDENTIFY: - self._channels.identify_ch = channel - - self.all_channels[channel.id] = channel - - @callback - def add_client_channels(self) -> None: - """Create client channels for all output clusters if in the registry.""" - for cluster_id, channel_class in zha_regs.CLIENT_CHANNELS_REGISTRY.items(): - cluster = self.endpoint.out_clusters.get(cluster_id) - if cluster is not None: - channel = channel_class(cluster, self) - self.client_channels[channel.id] = channel - - async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize claimed channels.""" - await self._execute_channel_tasks("async_initialize", from_cache) - - async def async_configure(self) -> None: - """Configure claimed channels.""" - await self._execute_channel_tasks("async_configure") - - async def _execute_channel_tasks(self, func_name: str, *args: Any) -> None: - """Add a throttled channel task and swallow exceptions.""" - channels = [*self.claimed_channels.values(), *self.client_channels.values()] - tasks = [getattr(ch, func_name)(*args) for ch in channels] - results = await asyncio.gather(*tasks, return_exceptions=True) - for channel, outcome in zip(channels, results): - if isinstance(outcome, Exception): - channel.warning( - "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome - ) - continue - channel.debug("'%s' stage succeeded", func_name) - - @callback - def async_new_entity( - self, - component: str, - entity_class: type[ZhaEntity], - unique_id: str, - channels: list[base.ZigbeeChannel], - ): - """Signal new entity addition.""" - self._channels.async_new_entity(component, entity_class, unique_id, channels) - - @callback - def async_send_signal(self, signal: str, *args: Any) -> None: - """Send a signal through hass dispatcher.""" - self._channels.async_send_signal(signal, *args) - - @callback - def claim_channels(self, channels: list[base.ZigbeeChannel]) -> None: - """Claim a channel.""" - self.claimed_channels.update({ch.id: ch for ch in channels}) - - @callback - def unclaimed_channels(self) -> list[base.ZigbeeChannel]: - """Return a list of available (unclaimed) channels.""" - claimed = set(self.claimed_channels) - available = set(self.all_channels) - return [self.all_channels[chan_id] for chan_id in (available - claimed)] - - @callback - def zha_send_event(self, event_data: dict[str, Any]) -> None: - """Relay events to hass.""" - self._channels.zha_send_event( - { - const.ATTR_UNIQUE_ID: self.unique_id, - const.ATTR_ENDPOINT_ID: self.id, - **event_data, - } - ) diff --git a/homeassistant/components/zha/core/channels/helpers.py b/homeassistant/components/zha/core/channels/helpers.py deleted file mode 100644 index 2297af312eb..00000000000 --- a/homeassistant/components/zha/core/channels/helpers.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Helpers for use with ZHA Zigbee channels.""" -from .base import ZigbeeChannel - - -def is_hue_motion_sensor(channel: ZigbeeChannel) -> bool: - """Return true if the manufacturer and model match known Hue motion sensor models.""" - return channel.cluster.endpoint.manufacturer in ( - "Philips", - "Signify Netherlands B.V.", - ) and channel.cluster.endpoint.model in ( - "SML001", - "SML002", - "SML003", - "SML004", - ) diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py deleted file mode 100644 index 51d837a8014..00000000000 --- a/homeassistant/components/zha/core/channels/protocol.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Protocol channels module for Zigbee Home Automation.""" -from zigpy.zcl.clusters import protocol - -from .. import registries -from .base import ZigbeeChannel - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogInputExtended.cluster_id) -class AnalogInputExtended(ZigbeeChannel): - """Analog Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogInputRegular.cluster_id) -class AnalogInputRegular(ZigbeeChannel): - """Analog Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogOutputExtended.cluster_id) -class AnalogOutputExtended(ZigbeeChannel): - """Analog Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogOutputRegular.cluster_id) -class AnalogOutputRegular(ZigbeeChannel): - """Analog Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogValueExtended.cluster_id) -class AnalogValueExtended(ZigbeeChannel): - """Analog Value Extended edition channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.AnalogValueRegular.cluster_id) -class AnalogValueRegular(ZigbeeChannel): - """Analog Value Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BacnetProtocolTunnel.cluster_id) -class BacnetProtocolTunnel(ZigbeeChannel): - """Bacnet Protocol Tunnel channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryInputExtended.cluster_id) -class BinaryInputExtended(ZigbeeChannel): - """Binary Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryInputRegular.cluster_id) -class BinaryInputRegular(ZigbeeChannel): - """Binary Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryOutputExtended.cluster_id) -class BinaryOutputExtended(ZigbeeChannel): - """Binary Output Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryOutputRegular.cluster_id) -class BinaryOutputRegular(ZigbeeChannel): - """Binary Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryValueExtended.cluster_id) -class BinaryValueExtended(ZigbeeChannel): - """Binary Value Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.BinaryValueRegular.cluster_id) -class BinaryValueRegular(ZigbeeChannel): - """Binary Value Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.GenericTunnel.cluster_id) -class GenericTunnel(ZigbeeChannel): - """Generic Tunnel channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateInputExtended.cluster_id -) -class MultiStateInputExtended(ZigbeeChannel): - """Multistate Input Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.MultistateInputRegular.cluster_id) -class MultiStateInputRegular(ZigbeeChannel): - """Multistate Input Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateOutputExtended.cluster_id -) -class MultiStateOutputExtended(ZigbeeChannel): - """Multistate Output Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateOutputRegular.cluster_id -) -class MultiStateOutputRegular(ZigbeeChannel): - """Multistate Output Regular channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register( - protocol.MultistateValueExtended.cluster_id -) -class MultiStateValueExtended(ZigbeeChannel): - """Multistate Value Extended channel.""" - - -@registries.ZIGBEE_CHANNEL_REGISTRY.register(protocol.MultistateValueRegular.cluster_id) -class MultiStateValueRegular(ZigbeeChannel): - """Multistate Value Regular channel.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py similarity index 73% rename from homeassistant/components/zha/core/channels/base.py rename to homeassistant/components/zha/core/cluster_handlers/__init__.py index 48f69ffbf2d..7863b043455 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -1,4 +1,4 @@ -"""Base classes for channels.""" +"""Cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import asyncio @@ -29,19 +29,19 @@ from ..const import ( ATTR_TYPE, ATTR_UNIQUE_ID, ATTR_VALUE, - CHANNEL_ZDO, + CLUSTER_HANDLER_ZDO, REPORT_CONFIG_ATTR_PER_REQ, SIGNAL_ATTR_UPDATED, - ZHA_CHANNEL_MSG, - ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_CFG_RPT, - ZHA_CHANNEL_MSG_DATA, - ZHA_CHANNEL_READS_PER_REQ, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA, + ZHA_CLUSTER_HANDLER_READS_PER_REQ, ) from ..helpers import LogMixin, retryable_req, safe_read if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint _LOGGER = logging.getLogger(__name__) @@ -56,31 +56,31 @@ class AttrReportConfig(TypedDict, total=True): config: tuple[int, int, int | float] -def parse_and_log_command(channel, tsn, command_id, args): +def parse_and_log_command(cluster_handler, tsn, command_id, args): """Parse and log a zigbee cluster command.""" try: - name = channel.cluster.server_commands[command_id].name + name = cluster_handler.cluster.server_commands[command_id].name except KeyError: name = f"0x{command_id:02X}" - channel.debug( + cluster_handler.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", name, args, - channel.cluster.cluster_id, + cluster_handler.cluster.cluster_id, tsn, ) return name -def decorate_command(channel, command): +def decorate_command(cluster_handler, command): """Wrap a cluster command to make it safe.""" @wraps(command) async def wrapper(*args, **kwds): try: result = await command(*args, **kwds) - channel.debug( + cluster_handler.debug( "executed '%s' command with args: '%s' kwargs: '%s' result: %s", command.__name__, args, @@ -90,7 +90,7 @@ def decorate_command(channel, command): return result except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: - channel.debug( + cluster_handler.debug( "command failed: '%s' args: '%s' kwargs '%s' exception: '%s'", command.__name__, args, @@ -102,63 +102,65 @@ def decorate_command(channel, command): return wrapper -class ChannelStatus(Enum): - """Status of a channel.""" +class ClusterHandlerStatus(Enum): + """Status of a cluster handler.""" CREATED = 1 CONFIGURED = 2 INITIALIZED = 3 -class ZigbeeChannel(LogMixin): - """Base channel for a Zigbee cluster.""" +class ClusterHandler(LogMixin): + """Base cluster handler for a Zigbee cluster.""" REPORT_CONFIG: tuple[AttrReportConfig, ...] = () BIND: bool = True - # Dict of attributes to read on channel initialization. + # Dict of attributes to read on cluster handler initialization. # Dict keys -- attribute ID or names, with bool value indicating whether a cached # attribute read is acceptable. ZCL_INIT_ATTRS: dict[int | str, bool] = {} - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize ZigbeeChannel.""" - self._generic_id = f"channel_0x{cluster.cluster_id:04x}" - self._ch_pool = ch_pool + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize ClusterHandler.""" + self._generic_id = f"cluster_handler_0x{cluster.cluster_id:04x}" + self._endpoint: Endpoint = endpoint self._cluster = cluster - self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" - unique_id = ch_pool.unique_id.replace("-", ":") + self._id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + unique_id = endpoint.unique_id.replace("-", ":") self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: - attr_def: ZCLAttributeDef | None = self.cluster.attributes_by_name.get( + attr_def: ZCLAttributeDef = self.cluster.attributes_by_name[ self.REPORT_CONFIG[0]["attr"] - ) - if attr_def is not None: - self.value_attribute = attr_def.id - else: - self.value_attribute = None - self._status = ChannelStatus.CREATED + ] + self.value_attribute = attr_def.id + self._status = ClusterHandlerStatus.CREATED self._cluster.add_listener(self) self.data_cache: dict[str, Enum] = {} + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return True + @property def id(self) -> str: - """Return channel id unique for this device only.""" + """Return cluster handler id unique for this device only.""" return self._id @property def generic_id(self): - """Return the generic id for this channel.""" + """Return the generic id for this cluster handler.""" return self._generic_id @property def unique_id(self): - """Return the unique id for this channel.""" + """Return the unique id for this cluster handler.""" return self._unique_id @property def cluster(self): - """Return the zigpy cluster for this channel.""" + """Return the zigpy cluster for this cluster handler.""" return self._cluster @property @@ -168,7 +170,7 @@ class ZigbeeChannel(LogMixin): @property def status(self): - """Return the status of the channel.""" + """Return the status of the cluster handler.""" return self._status def __hash__(self) -> int: @@ -178,7 +180,7 @@ class ZigbeeChannel(LogMixin): @callback def async_send_signal(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" - self._ch_pool.async_send_signal(signal, *args) + self._endpoint.async_send_signal(signal, *args) async def bind(self): """Bind a zigbee cluster. @@ -190,11 +192,11 @@ class ZigbeeChannel(LogMixin): res = await self.cluster.bind() self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "success": res[0] == 0, @@ -203,14 +205,17 @@ class ZigbeeChannel(LogMixin): ) except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( - "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) + "Failed to bind '%s' cluster: %s", + self.cluster.ep_attribute, + str(ex), + exc_info=ex, ) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_BIND, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "success": False, @@ -226,8 +231,11 @@ class ZigbeeChannel(LogMixin): """ event_data = {} kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: - kwargs["manufacturer"] = self._ch_pool.manufacturer_code + if ( + self.cluster.cluster_id >= 0xFC00 + and self._endpoint.device.manufacturer_code + ): + kwargs["manufacturer"] = self._endpoint.device.manufacturer_code for attr_report in self.REPORT_CONFIG: attr, config = attr_report["attr"], attr_report["config"] @@ -272,11 +280,11 @@ class ZigbeeChannel(LogMixin): ) async_dispatcher_send( - self._ch_pool.hass, - ZHA_CHANNEL_MSG, + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, { - ATTR_TYPE: ZHA_CHANNEL_MSG_CFG_RPT, - ZHA_CHANNEL_MSG_DATA: { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA: { "cluster_name": self.cluster.name, "cluster_id": self.cluster.cluster_id, "attributes": event_data, @@ -311,7 +319,6 @@ class ZigbeeChannel(LogMixin): for record in res if record.status != Status.SUCCESS ] - self.debug( "Successfully configured reporting for '%s' on '%s' cluster", set(attrs) - set(failed), @@ -326,43 +333,45 @@ class ZigbeeChannel(LogMixin): async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" - if not self._ch_pool.skip_configuration: + if not self._endpoint.device.skip_configuration: if self.BIND: self.debug("Performing cluster binding") await self.bind() if self.cluster.is_server: self.debug("Configuring cluster attribute reporting") await self.configure_reporting() - ch_specific_cfg = getattr(self, "async_configure_channel_specific", None) + ch_specific_cfg = getattr( + self, "async_configure_cluster_handler_specific", None + ) if ch_specific_cfg: - self.debug("Performing channel specific configuration") + self.debug("Performing cluster handler specific configuration") await ch_specific_cfg() - self.debug("finished channel configuration") + self.debug("finished cluster handler configuration") else: - self.debug("skipping channel configuration") - self._status = ChannelStatus.CONFIGURED + self.debug("skipping cluster handler configuration") + self._status = ClusterHandlerStatus.CONFIGURED @retryable_req(delays=(1, 1, 3)) async def async_initialize(self, from_cache: bool) -> None: - """Initialize channel.""" - if not from_cache and self._ch_pool.skip_configuration: - self.debug("Skipping channel initialization") - self._status = ChannelStatus.INITIALIZED + """Initialize cluster handler.""" + if not from_cache and self._endpoint.device.skip_configuration: + self.debug("Skipping cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED return - self.debug("initializing channel: from_cache: %s", from_cache) + self.debug("initializing cluster handler: from_cache: %s", from_cache) cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached] uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) if cached: - self.debug("initializing cached channel attributes: %s", cached) + self.debug("initializing cached cluster handler attributes: %s", cached) await self._get_attributes( True, cached, from_cache=True, only_cache=from_cache ) if uncached: self.debug( - "initializing uncached channel attributes: %s - from cache[%s]", + "initializing uncached cluster handler attributes: %s - from cache[%s]", uncached, from_cache, ) @@ -370,13 +379,17 @@ class ZigbeeChannel(LogMixin): True, uncached, from_cache=from_cache, only_cache=from_cache ) - ch_specific_init = getattr(self, "async_initialize_channel_specific", None) + ch_specific_init = getattr( + self, "async_initialize_cluster_handler_specific", None + ) if ch_specific_init: - self.debug("Performing channel specific initialization: %s", uncached) + self.debug( + "Performing cluster handler specific initialization: %s", uncached + ) await ch_specific_init(from_cache=from_cache) - self.debug("finished channel initialization") - self._status = ChannelStatus.INITIALIZED + self.debug("finished cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED @callback def cluster_command(self, tsn, command_id, args): @@ -411,7 +424,7 @@ class ZigbeeChannel(LogMixin): else: raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") - self._ch_pool.zha_send_event( + self._endpoint.send_event( { ATTR_UNIQUE_ID: self.unique_id, ATTR_CLUSTER_ID: self.cluster.cluster_id, @@ -434,7 +447,7 @@ class ZigbeeChannel(LogMixin): async def get_attribute_value(self, attribute, from_cache=True): """Get the value for an attribute.""" manufacturer = None - manufacturer_code = self._ch_pool.manufacturer_code + manufacturer_code = self._endpoint.device.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code result = await safe_read( @@ -455,11 +468,11 @@ class ZigbeeChannel(LogMixin): ) -> dict[int | str, Any]: """Get the values for a list of attributes.""" manufacturer = None - manufacturer_code = self._ch_pool.manufacturer_code + manufacturer_code = self._endpoint.device.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code - chunk = attributes[:ZHA_CHANNEL_READS_PER_REQ] - rest = attributes[ZHA_CHANNEL_READS_PER_REQ:] + chunk = attributes[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = attributes[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] result = {} while chunk: try: @@ -480,8 +493,8 @@ class ZigbeeChannel(LogMixin): ) if raise_exceptions: raise - chunk = rest[:ZHA_CHANNEL_READS_PER_REQ] - rest = rest[ZHA_CHANNEL_READS_PER_REQ:] + chunk = rest[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] return result get_attributes = partialmethod(_get_attributes, False) @@ -489,7 +502,7 @@ class ZigbeeChannel(LogMixin): def log(self, level, msg, *args, **kwargs): """Log a message.""" msg = f"[%s:%s]: {msg}" - args = (self._ch_pool.nwk, self._id) + args + args = (self._endpoint.device.nwk, self._id) + args _LOGGER.log(level, msg, *args, **kwargs) def __getattr__(self, name): @@ -501,31 +514,31 @@ class ZigbeeChannel(LogMixin): return self.__getattribute__(name) -class ZDOChannel(LogMixin): - """Channel for ZDO events.""" +class ZDOClusterHandler(LogMixin): + """Cluster handler for ZDO events.""" - def __init__(self, cluster, device): - """Initialize ZDOChannel.""" - self.name = CHANNEL_ZDO - self._cluster = cluster + def __init__(self, device): + """Initialize ZDOClusterHandler.""" + self.name = CLUSTER_HANDLER_ZDO + self._cluster = device.device.endpoints[0] self._zha_device = device - self._status = ChannelStatus.CREATED + self._status = ClusterHandlerStatus.CREATED self._unique_id = f"{str(device.ieee)}:{device.name}_ZDO" self._cluster.add_listener(self) @property def unique_id(self): - """Return the unique id for this channel.""" + """Return the unique id for this cluster handler.""" return self._unique_id @property def cluster(self): - """Return the aigpy cluster for this channel.""" + """Return the aigpy cluster for this cluster handler.""" return self._cluster @property def status(self): - """Return the status of the channel.""" + """Return the status of the cluster handler.""" return self._status @callback @@ -537,12 +550,12 @@ class ZDOChannel(LogMixin): """Permit handler.""" async def async_initialize(self, from_cache): - """Initialize channel.""" - self._status = ChannelStatus.INITIALIZED + """Initialize cluster handler.""" + self._status = ClusterHandlerStatus.INITIALIZED async def async_configure(self): - """Configure channel.""" - self._status = ChannelStatus.CONFIGURED + """Configure cluster handler.""" + self._status = ClusterHandlerStatus.CONFIGURED def log(self, level, msg, *args, **kwargs): """Log a message.""" @@ -551,8 +564,8 @@ class ZDOChannel(LogMixin): _LOGGER.log(level, msg, *args, **kwargs) -class ClientChannel(ZigbeeChannel): - """Channel listener for Zigbee client (output) clusters.""" +class ClientClusterHandler(ClusterHandler): + """ClusterHandler for Zigbee client (output) clusters.""" @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py similarity index 84% rename from homeassistant/components/zha/core/channels/closures.py rename to homeassistant/components/zha/core/cluster_handlers/closures.py index de2dcaf38e9..ab58405b974 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,16 +1,16 @@ -"""Closures channels module for Zigbee Home Automation.""" +"""Closures cluster handlers module for Zigbee Home Automation.""" from zigpy.zcl.clusters import closures from homeassistant.core import callback +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.DoorLock.cluster_id) -class DoorLockChannel(ZigbeeChannel): - """Door lock channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) +class DoorLockClusterHandler(ClusterHandler): + """Door lock cluster handler.""" _value_attribute = 0 REPORT_CONFIG = ( @@ -107,19 +107,20 @@ class DoorLockChannel(ZigbeeChannel): return result -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.Shade.cluster_id) -class Shade(ZigbeeChannel): - """Shade channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.Shade.cluster_id) +class Shade(ClusterHandler): + """Shade cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCoveringClient(ClientChannel): - """Window client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCoveringClient(ClientClusterHandler): + """Window client cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCovering(ZigbeeChannel): - """Window channel.""" +@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) +class WindowCovering(ClusterHandler): + """Window cluster handler.""" _value_attribute = 8 REPORT_CONFIG = ( diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py similarity index 66% rename from homeassistant/components/zha/core/channels/general.py rename to homeassistant/components/zha/core/cluster_handlers/general.py index 47d0cafb01c..d4014bbf697 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -1,4 +1,4 @@ -"""General channels module for Zigbee Home Automation.""" +"""General cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import asyncio @@ -14,6 +14,12 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later +from . import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + parse_and_log_command, +) from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -27,21 +33,20 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command from .helpers import is_hue_motion_sensor if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Alarms.cluster_id) -class Alarms(ZigbeeChannel): - """Alarms channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Alarms.cluster_id) +class Alarms(ClusterHandler): + """Alarms cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(ZigbeeChannel): - """Analog Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogInput.cluster_id) +class AnalogInput(ClusterHandler): + """Analog Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -49,9 +54,9 @@ class AnalogInput(ZigbeeChannel): @registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(ZigbeeChannel): - """Analog Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogOutput.cluster_id) +class AnalogOutput(ClusterHandler): + """Analog Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), @@ -120,24 +125,26 @@ class AnalogOutput(ZigbeeChannel): return False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(ZigbeeChannel): - """Analog Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) +class AnalogValue(ClusterHandler): + """Analog Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.ApplianceControl.cluster_id) -class ApplianceContorl(ZigbeeChannel): - """Appliance Control channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.ApplianceControl.cluster_id +) +class ApplianceContorl(ClusterHandler): + """Appliance Control cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.Basic.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Basic.cluster_id) -class BasicChannel(ZigbeeChannel): - """Channel to interact with the basic cluster.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Basic.cluster_id) +class BasicClusterHandler(ClusterHandler): + """Cluster handler to interact with the basic cluster.""" UNKNOWN = 0 BATTERY = 3 @@ -153,9 +160,9 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Basic channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Basic cluster handler.""" + super().__init__(cluster, endpoint) if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name self.ZCL_INIT_ATTRS.copy() @@ -169,41 +176,43 @@ class BasicChannel(ZigbeeChannel): self.ZCL_INIT_ATTRS["transmit_power"] = True -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(ZigbeeChannel): - """Binary Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryInput.cluster_id) +class BinaryInput(ClusterHandler): + """Binary Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(ZigbeeChannel): - """Binary Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryOutput.cluster_id) +class BinaryOutput(ClusterHandler): + """Binary Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(ZigbeeChannel): - """Binary Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryValue.cluster_id) +class BinaryValue(ClusterHandler): + """Binary Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Commissioning.cluster_id) -class Commissioning(ZigbeeChannel): - """Commissioning channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Commissioning.cluster_id) +class Commissioning(ClusterHandler): + """Commissioning cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.DeviceTemperature.cluster_id) -class DeviceTemperature(ZigbeeChannel): - """Device Temperature channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.DeviceTemperature.cluster_id +) +class DeviceTemperature(ClusterHandler): + """Device Temperature cluster handler.""" REPORT_CONFIG = ( { @@ -213,23 +222,23 @@ class DeviceTemperature(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) -class GreenPowerProxy(ZigbeeChannel): - """Green Power Proxy channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.GreenPowerProxy.cluster_id) +class GreenPowerProxy(ClusterHandler): + """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Groups.cluster_id) -class Groups(ZigbeeChannel): - """Groups channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Groups.cluster_id) +class Groups(ClusterHandler): + """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Identify.cluster_id) -class Identify(ZigbeeChannel): - """Identify channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Identify.cluster_id) +class Identify(ClusterHandler): + """Identify cluster handler.""" BIND: bool = False @@ -242,15 +251,15 @@ class Identify(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlClientChannel(ClientChannel): +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClientClusterHandler(ClientClusterHandler): """LevelControl client cluster.""" @registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.LevelControl.cluster_id) -class LevelControlChannel(ZigbeeChannel): - """Channel for the LevelControl Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +class LevelControlClusterHandler(ClusterHandler): + """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) @@ -299,52 +308,54 @@ class LevelControlChannel(ZigbeeChannel): self.async_send_signal(f"{self.unique_id}_{command}", level) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(ZigbeeChannel): - """Multistate Input channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateInput.cluster_id) +class MultistateInput(ClusterHandler): + """Multistate Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) -class MultistateOutput(ZigbeeChannel): - """Multistate Output channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.MultistateOutput.cluster_id +) +class MultistateOutput(ClusterHandler): + """Multistate Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(ZigbeeChannel): - """Multistate Value channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateValue.cluster_id) +class MultistateValue(ClusterHandler): + """Multistate Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id) -class OnOffClientChannel(ClientChannel): - """OnOff client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClientClusterHandler(ClientClusterHandler): + """OnOff client cluster handler.""" @registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOff.cluster_id) -class OnOffChannel(ZigbeeChannel): - """Channel for the OnOff Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +class OnOffClusterHandler(ClusterHandler): + """Cluster handler for the OnOff Zigbee cluster.""" - ON_OFF = 0 + ON_OFF = general.OnOff.attributes_by_name["on_off"].id REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) ZCL_INIT_ATTRS = { "start_up_on_off": True, } - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize OnOffChannel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize OnOffClusterHandler.""" + super().__init__(cluster, endpoint) self._off_listener = None if self.cluster.endpoint.model in ( @@ -363,6 +374,15 @@ class OnOffChannel(ZigbeeChannel): if self.cluster.endpoint.model == "TS011F": self.ZCL_INIT_ATTRS["child_lock"] = True + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return not ( + cluster.endpoint.device.manufacturer == "Konke" + and cluster.endpoint.device.model + in ("3AFE280100510001", "3AFE170100510001") + ) + @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" @@ -404,7 +424,7 @@ class OnOffChannel(ZigbeeChannel): self.cluster.update_attribute(self.ON_OFF, t.Bool.true) if on_time > 0: self._off_listener = async_call_later( - self._ch_pool.hass, + self._endpoint.device.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, ) @@ -426,24 +446,26 @@ class OnOffChannel(ZigbeeChannel): ) async def async_update(self): - """Initialize channel.""" + """Initialize cluster handler.""" if self.cluster.is_client: return - from_cache = not self._ch_pool.is_mains_powered + from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) await super().async_update() -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOffConfiguration.cluster_id) -class OnOffConfiguration(ZigbeeChannel): - """OnOff Configuration channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.OnOffConfiguration.cluster_id +) +class OnOffConfiguration(ClusterHandler): + """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id) -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) -class Ota(ClientChannel): - """OTA Channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) +class Ota(ClientClusterHandler): + """OTA cluster handler.""" BIND: bool = False @@ -457,21 +479,21 @@ class Ota(ClientChannel): else: cmd_name = command_id - signal_id = self._ch_pool.unique_id.split("-")[0] + signal_id = self._endpoint.unique_id.split("-")[0] if cmd_name == "query_next_image": assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Partition.cluster_id) -class Partition(ZigbeeChannel): - """Partition channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Partition.cluster_id) +class Partition(ClusterHandler): + """Partition cluster handler.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(general.PollControl.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PollControl.cluster_id) -class PollControl(ZigbeeChannel): - """Poll Control channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PollControl.cluster_id) +class PollControl(ClusterHandler): + """Poll Control cluster handler.""" CHECKIN_INTERVAL = 55 * 60 * 4 # 55min CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s @@ -480,8 +502,8 @@ class PollControl(ZigbeeChannel): 4476, } # IKEA - async def async_configure_channel_specific(self) -> None: - """Configure channel: set check-in interval.""" + async def async_configure_cluster_handler_specific(self) -> None: + """Configure cluster handler: set check-in interval.""" try: res = await self.cluster.write_attributes( {"checkin_interval": self.CHECKIN_INTERVAL} @@ -508,7 +530,7 @@ class PollControl(ZigbeeChannel): async def check_in_response(self, tsn: int) -> None: """Respond to checkin command.""" await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn) - if self._ch_pool.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: + if self._endpoint.device.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: await self.set_long_poll_interval(self.LONG_POLL) await self.fast_poll_stop() @@ -518,9 +540,11 @@ class PollControl(ZigbeeChannel): self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerConfiguration.cluster_id) -class PowerConfigurationChannel(ZigbeeChannel): - """Channel for the zigbee power configuration cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + general.PowerConfiguration.cluster_id +) +class PowerConfigurationClusterHandler(ClusterHandler): + """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), @@ -529,8 +553,8 @@ class PowerConfigurationChannel(ZigbeeChannel): ), ) - def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: - """Initialize channel specific attrs.""" + def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: + """Initialize cluster handler specific attrs.""" attributes = [ "battery_size", "battery_quantity", @@ -540,26 +564,26 @@ class PowerConfigurationChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id) -class PowerProfile(ZigbeeChannel): - """Power Profile channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PowerProfile.cluster_id) +class PowerProfile(ClusterHandler): + """Power Profile cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.RSSILocation.cluster_id) -class RSSILocation(ZigbeeChannel): - """RSSI Location channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.RSSILocation.cluster_id) +class RSSILocation(ClusterHandler): + """RSSI Location cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Scenes.cluster_id) -class ScenesClientChannel(ClientChannel): - """Scenes channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class ScenesClientClusterHandler(ClientClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id) -class Scenes(ZigbeeChannel): - """Scenes channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +class Scenes(ClusterHandler): + """Scenes cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Time.cluster_id) -class Time(ZigbeeChannel): - """Time channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Time.cluster_id) +class Time(ClusterHandler): + """Time cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/helpers.py b/homeassistant/components/zha/core/cluster_handlers/helpers.py new file mode 100644 index 00000000000..17bc5763977 --- /dev/null +++ b/homeassistant/components/zha/core/cluster_handlers/helpers.py @@ -0,0 +1,15 @@ +"""Helpers for use with ZHA Zigbee cluster handlers.""" +from . import ClusterHandler + + +def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Hue motion sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "Philips", + "Signify Netherlands B.V.", + ) and cluster_handler.cluster.endpoint.model in ( + "SML001", + "SML002", + "SML003", + "SML004", + ) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py similarity index 78% rename from homeassistant/components/zha/core/channels/homeautomation.py rename to homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 69295ef6f81..981ed08ba00 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -1,53 +1,55 @@ -"""Home automation channels module for Zigbee Home Automation.""" +"""Home automation cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import enum from zigpy.zcl.clusters import homeautomation +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( - CHANNEL_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceEventAlerts.cluster_id ) -class ApplianceEventAlerts(ZigbeeChannel): - """Appliance Event Alerts channel.""" +class ApplianceEventAlerts(ClusterHandler): + """Appliance Event Alerts cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceIdentification.cluster_id ) -class ApplianceIdentification(ZigbeeChannel): - """Appliance Identification channel.""" +class ApplianceIdentification(ClusterHandler): + """Appliance Identification cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ApplianceStatistics.cluster_id ) -class ApplianceStatistics(ZigbeeChannel): - """Appliance Statistics channel.""" +class ApplianceStatistics(ClusterHandler): + """Appliance Statistics cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(homeautomation.Diagnostic.cluster_id) -class Diagnostic(ZigbeeChannel): - """Diagnostic channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + homeautomation.Diagnostic.cluster_id +) +class Diagnostic(ClusterHandler): + """Diagnostic cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.ElectricalMeasurement.cluster_id ) -class ElectricalMeasurementChannel(ZigbeeChannel): - """Channel that polls active power level.""" +class ElectricalMeasurementClusterHandler(ClusterHandler): + """Cluster handler that polls active power level.""" - CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT + CLUSTER_HANDLER_NAME = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT class MeasurementType(enum.IntFlag): """Measurement types.""" @@ -91,7 +93,7 @@ class ElectricalMeasurementChannel(ZigbeeChannel): """Retrieve latest state.""" self.debug("async_update") - # This is a polling channel. Don't allow cache. + # This is a polling cluster handler. Don't allow cache. attrs = [ a["attr"] for a in self.REPORT_CONFIG @@ -165,8 +167,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( homeautomation.MeterIdentification.cluster_id ) -class MeterIdentification(ZigbeeChannel): - """Metering Identification channel.""" +class MeterIdentification(ClusterHandler): + """Metering Identification cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py similarity index 91% rename from homeassistant/components/zha/core/channels/hvac.py rename to homeassistant/components/zha/core/cluster_handlers/hvac.py index 4a73a643b75..94154564e8c 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -1,4 +1,4 @@ -"""HVAC channels module for Zigbee Home Automation. +"""HVAC cluster handlers module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/integrations/zha/ @@ -14,6 +14,7 @@ from zigpy.zcl.foundation import Status from homeassistant.core import callback +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_MAX_INT, @@ -21,7 +22,6 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) @@ -29,14 +29,14 @@ REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Dehumidification.cluster_id) -class Dehumidification(ZigbeeChannel): - """Dehumidification channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Dehumidification.cluster_id) +class Dehumidification(ClusterHandler): + """Dehumidification cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Fan.cluster_id) -class FanChannel(ZigbeeChannel): - """Fan channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Fan.cluster_id) +class FanClusterHandler(ClusterHandler): + """Fan cluster handler.""" _value_attribute = 0 @@ -79,14 +79,14 @@ class FanChannel(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Pump.cluster_id) -class Pump(ZigbeeChannel): - """Pump channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Pump.cluster_id) +class Pump(ClusterHandler): + """Pump cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Thermostat.cluster_id) -class ThermostatChannel(ZigbeeChannel): - """Thermostat channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Thermostat.cluster_id) +class ThermostatClusterHandler(ClusterHandler): + """Thermostat cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), @@ -314,6 +314,6 @@ class ThermostatChannel(ZigbeeChannel): return all(record.status == Status.SUCCESS for record in res[0]) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.UserInterface.cluster_id) -class UserInterface(ZigbeeChannel): - """User interface (thermostat) channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.UserInterface.cluster_id) +class UserInterface(ClusterHandler): + """User interface (thermostat) cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py similarity index 80% rename from homeassistant/components/zha/core/channels/lighting.py rename to homeassistant/components/zha/core/cluster_handlers/lighting.py index 55d77d507fd..56f3c701aa1 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -1,29 +1,29 @@ -"""Lighting channels module for Zigbee Home Automation.""" +"""Lighting cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations from functools import cached_property from zigpy.zcl.clusters import lighting +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import REPORT_CONFIG_DEFAULT -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Ballast.cluster_id) -class Ballast(ZigbeeChannel): - """Ballast channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Ballast.cluster_id) +class Ballast(ClusterHandler): + """Ballast cluster handler.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(lighting.Color.cluster_id) -class ColorClientChannel(ClientChannel): - """Color client channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +class ColorClientClusterHandler(ClientClusterHandler): + """Color client cluster handler.""" @registries.BINDABLE_CLUSTERS.register(lighting.Color.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Color.cluster_id) -class ColorChannel(ZigbeeChannel): - """Color channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +class ColorClusterHandler(ClusterHandler): + """Color cluster handler.""" CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 @@ -98,7 +98,7 @@ class ColorChannel(ZigbeeChannel): @property def min_mireds(self) -> int: - """Return the coldest color_temp that this channel supports.""" + """Return the coldest color_temp that this cluster handler supports.""" min_mireds = self.cluster.get("color_temp_physical_min", self.MIN_MIREDS) if min_mireds == 0: self.warning( @@ -113,7 +113,7 @@ class ColorChannel(ZigbeeChannel): @property def max_mireds(self) -> int: - """Return the warmest color_temp that this channel supports.""" + """Return the warmest color_temp that this cluster handler supports.""" max_mireds = self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) if max_mireds == 0: self.warning( @@ -128,7 +128,7 @@ class ColorChannel(ZigbeeChannel): @property def hs_supported(self) -> bool: - """Return True if the channel supports hue and saturation.""" + """Return True if the cluster handler supports hue and saturation.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Hue_and_saturation @@ -137,7 +137,7 @@ class ColorChannel(ZigbeeChannel): @property def enhanced_hue_supported(self) -> bool: - """Return True if the channel supports enhanced hue and saturation.""" + """Return True if the cluster handler supports enhanced hue and saturation.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Enhanced_hue in self.color_capabilities @@ -145,7 +145,7 @@ class ColorChannel(ZigbeeChannel): @property def xy_supported(self) -> bool: - """Return True if the channel supports xy.""" + """Return True if the cluster handler supports xy.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.XY_attributes @@ -154,7 +154,7 @@ class ColorChannel(ZigbeeChannel): @property def color_temp_supported(self) -> bool: - """Return True if the channel supports color temperature.""" + """Return True if the cluster handler supports color temperature.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Color_temperature @@ -163,7 +163,7 @@ class ColorChannel(ZigbeeChannel): @property def color_loop_supported(self) -> bool: - """Return True if the channel supports color loop.""" + """Return True if the cluster handler supports color loop.""" return ( self.color_capabilities is not None and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities @@ -171,10 +171,10 @@ class ColorChannel(ZigbeeChannel): @property def options(self) -> lighting.Color.Options: - """Return ZCL options of the channel.""" + """Return ZCL options of the cluster handler.""" return lighting.Color.Options(self.cluster.get("options", 0)) @property def execute_if_off_supported(self) -> bool: - """Return True if the channel can execute commands when off.""" + """Return True if the cluster handler can execute commands when off.""" return lighting.Color.Options.Execute_if_off in self.options diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py similarity index 69% rename from homeassistant/components/zha/core/channels/lightlink.py rename to homeassistant/components/zha/core/cluster_handlers/lightlink.py index cd3fc00ac28..437a4b4ecf8 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -1,29 +1,29 @@ -"""Lightlink channels module for Zigbee Home Automation.""" +"""Lightlink cluster handlers module for Zigbee Home Automation.""" import asyncio import zigpy.exceptions from zigpy.zcl.clusters import lightlink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand +from . import ClusterHandler, ClusterHandlerStatus from .. import registries -from .base import ChannelStatus, ZigbeeChannel -@registries.CHANNEL_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(lightlink.LightLink.cluster_id) -class LightLink(ZigbeeChannel): - """Lightlink channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lightlink.LightLink.cluster_id) +class LightLink(ClusterHandler): + """Lightlink cluster handler.""" BIND: bool = False async def async_configure(self) -> None: """Add Coordinator to LightLink group.""" - if self._ch_pool.skip_configuration: - self._status = ChannelStatus.CONFIGURED + if self._endpoint.device.skip_configuration: + self._status = ClusterHandlerStatus.CONFIGURED return - application = self._ch_pool.endpoint.device.application + application = self._endpoint.zigpy_endpoint.device.application try: coordinator = application.get_device(application.state.node_info.ieee) except KeyError: diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py similarity index 76% rename from homeassistant/components/zha/core/channels/manufacturerspecific.py rename to homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 20848453e2a..d20888e1f55 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -1,4 +1,4 @@ -"""Manufacturer specific channels module for Zigbee Home Automation.""" +"""Manufacturer specific cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import logging @@ -10,6 +10,7 @@ import zigpy.zcl from homeassistant.core import callback +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .. import registries from ..const import ( ATTR_ATTRIBUTE_ID, @@ -23,17 +24,18 @@ from ..const import ( SIGNAL_ATTR_UPDATED, UNKNOWN, ) -from .base import AttrReportConfig, ClientChannel, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint _LOGGER = logging.getLogger(__name__) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.SMARTTHINGS_HUMIDITY_CLUSTER) -class SmartThingsHumidity(ZigbeeChannel): - """Smart Things Humidity channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.SMARTTHINGS_HUMIDITY_CLUSTER +) +class SmartThingsHumidity(ClusterHandler): + """Smart Things Humidity cluster handler.""" REPORT_CONFIG = ( { @@ -43,32 +45,34 @@ class SmartThingsHumidity(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFD00) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFD00) -class OsramButton(ZigbeeChannel): - """Osram button channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) +class OsramButton(ClusterHandler): + """Osram button cluster handler.""" REPORT_CONFIG = () -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) -class PhillipsRemote(ZigbeeChannel): - """Phillips remote channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) +class PhillipsRemote(ClusterHandler): + """Phillips remote cluster handler.""" REPORT_CONFIG = () -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.TUYA_MANUFACTURER_CLUSTER) -class TuyaChannel(ZigbeeChannel): - """Channel for the Tuya manufacturer Zigbee cluster.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.TUYA_MANUFACTURER_CLUSTER +) +class TuyaClusterHandler(ClusterHandler): + """Cluster handler for the Tuya manufacturer Zigbee cluster.""" REPORT_CONFIG = () - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize TuyaChannel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize TuyaClusterHandler.""" + super().__init__(cluster, endpoint) if self.cluster.endpoint.manufacturer in ( "_TZE200_7tdtqgwv", @@ -94,16 +98,16 @@ class TuyaChannel(ZigbeeChannel): } -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0) -class OppleRemote(ZigbeeChannel): - """Opple channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) +class OppleRemote(ClusterHandler): + """Opple cluster handler.""" REPORT_CONFIG = () - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Opple channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Opple cluster handler.""" + super().__init__(cluster, endpoint) if self.cluster.endpoint.model == "lumi.motion.ac02": self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name "detection_interval": True, @@ -162,8 +166,8 @@ class OppleRemote(ZigbeeChannel): "linkage_alarm": True, } - async def async_initialize_channel_specific(self, from_cache: bool) -> None: - """Initialize channel specific.""" + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + """Initialize cluster handler specific.""" if self.cluster.endpoint.model in ("lumi.motion.ac02", "lumi.motion.agl04"): interval = self.cluster.get("detection_interval", self.cluster.get(0x0102)) if interval is not None: @@ -171,11 +175,11 @@ class OppleRemote(ZigbeeChannel): self.cluster.endpoint.ias_zone.reset_s = int(interval) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) -class SmartThingsAcceleration(ZigbeeChannel): - """Smart Things Acceleration channel.""" +class SmartThingsAcceleration(ClusterHandler): + """Smart Things Acceleration cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="acceleration", config=REPORT_CONFIG_ASAP), @@ -184,6 +188,15 @@ class SmartThingsAcceleration(ZigbeeChannel): AttrReportConfig(attr="z_axis", config=REPORT_CONFIG_ASAP), ) + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return cluster.endpoint.device.manufacturer in ( + "CentraLite", + "Samjin", + "SmartThings", + ) + @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" @@ -211,9 +224,9 @@ class SmartThingsAcceleration(ZigbeeChannel): ) -@registries.CLIENT_CHANNELS_REGISTRY.register(0xFC31) -class InovelliNotificationChannel(ClientChannel): - """Inovelli Notification channel.""" +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliNotificationClusterHandler(ClientClusterHandler): + """Inovelli Notification cluster handler.""" @callback def attribute_updated(self, attrid, value): @@ -224,9 +237,9 @@ class InovelliNotificationChannel(ClientChannel): """Handle a cluster command received on this cluster.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC31) -class InovelliConfigEntityChannel(ZigbeeChannel): - """Inovelli Configuration Entity channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliConfigEntityClusterHandler(ClusterHandler): + """Inovelli Configuration Entity cluster handler.""" REPORT_CONFIG = () ZCL_INIT_ATTRS = { @@ -307,10 +320,12 @@ class InovelliConfigEntityChannel(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.IKEA_AIR_PURIFIER_CLUSTER) -class IkeaAirPurifierChannel(ZigbeeChannel): - """IKEA Air Purifier channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.IKEA_AIR_PURIFIER_CLUSTER +) +class IkeaAirPurifierClusterHandler(ClusterHandler): + """IKEA Air Purifier cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), @@ -360,9 +375,9 @@ class IkeaAirPurifierChannel(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC80) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC80) -class IkeaRemote(ZigbeeChannel): - """Ikea Matter remote channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) +class IkeaRemote(ClusterHandler): + """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py similarity index 52% rename from homeassistant/components/zha/core/channels/measurement.py rename to homeassistant/components/zha/core/cluster_handlers/measurement.py index be61a75962e..8b882a299f6 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -1,4 +1,4 @@ -"""Measurement channels module for Zigbee Home Automation.""" +"""Measurement cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations from typing import TYPE_CHECKING @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import measurement +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_DEFAULT, @@ -13,55 +14,58 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from .base import AttrReportConfig, ZigbeeChannel from .helpers import is_hue_motion_sensor if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) -class FlowMeasurement(ZigbeeChannel): - """Flow Measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.FlowMeasurement.cluster_id +) +class FlowMeasurement(ClusterHandler): + """Flow Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.IlluminanceLevelSensing.cluster_id ) -class IlluminanceLevelSensing(ZigbeeChannel): - """Illuminance Level Sensing channel.""" +class IlluminanceLevelSensing(ClusterHandler): + """Illuminance Level Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.IlluminanceMeasurement.cluster_id ) -class IlluminanceMeasurement(ZigbeeChannel): - """Illuminance Measurement channel.""" +class IlluminanceMeasurement(ClusterHandler): + """Illuminance Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id) -class OccupancySensing(ZigbeeChannel): - """Occupancy Sensing channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.OccupancySensing.cluster_id +) +class OccupancySensing(ClusterHandler): + """Occupancy Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), ) - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize Occupancy channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Occupancy cluster handler.""" + super().__init__(cluster, endpoint) if is_hue_motion_sensor(self): self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name self.ZCL_INIT_ATTRS.copy() @@ -69,18 +73,22 @@ class OccupancySensing(ZigbeeChannel): self.ZCL_INIT_ATTRS["sensitivity"] = True -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) -class PressureMeasurement(ZigbeeChannel): - """Pressure measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.PressureMeasurement.cluster_id +) +class PressureMeasurement(ClusterHandler): + """Pressure measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id) -class RelativeHumidity(ZigbeeChannel): - """Relative Humidity measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.RelativeHumidity.cluster_id +) +class RelativeHumidity(ClusterHandler): + """Relative Humidity measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -90,9 +98,11 @@ class RelativeHumidity(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.SoilMoisture.cluster_id) -class SoilMoisture(ZigbeeChannel): - """Soil Moisture measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + measurement.SoilMoisture.cluster_id +) +class SoilMoisture(ClusterHandler): + """Soil Moisture measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -102,9 +112,9 @@ class SoilMoisture(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.LeafWetness.cluster_id) -class LeafWetness(ZigbeeChannel): - """Leaf Wetness measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.LeafWetness.cluster_id) +class LeafWetness(ClusterHandler): + """Leaf Wetness measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -114,11 +124,11 @@ class LeafWetness(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.TemperatureMeasurement.cluster_id ) -class TemperatureMeasurement(ZigbeeChannel): - """Temperature measurement channel.""" +class TemperatureMeasurement(ClusterHandler): + """Temperature measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -128,11 +138,11 @@ class TemperatureMeasurement(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.CarbonMonoxideConcentration.cluster_id ) -class CarbonMonoxideConcentration(ZigbeeChannel): - """Carbon Monoxide measurement channel.""" +class CarbonMonoxideConcentration(ClusterHandler): + """Carbon Monoxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -142,11 +152,11 @@ class CarbonMonoxideConcentration(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.CarbonDioxideConcentration.cluster_id ) -class CarbonDioxideConcentration(ZigbeeChannel): - """Carbon Dioxide measurement channel.""" +class CarbonDioxideConcentration(ClusterHandler): + """Carbon Dioxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -156,9 +166,9 @@ class CarbonDioxideConcentration(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PM25.cluster_id) -class PM25(ZigbeeChannel): - """Particulate Matter 2.5 microns or less measurement channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.PM25.cluster_id) +class PM25(ClusterHandler): + """Particulate Matter 2.5 microns or less measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( @@ -168,11 +178,11 @@ class PM25(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register( +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( measurement.FormaldehydeConcentration.cluster_id ) -class FormaldehydeConcentration(ZigbeeChannel): - """Formaldehyde measurement channel.""" +class FormaldehydeConcentration(ClusterHandler): + """Formaldehyde measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( diff --git a/homeassistant/components/zha/core/cluster_handlers/protocol.py b/homeassistant/components/zha/core/cluster_handlers/protocol.py new file mode 100644 index 00000000000..6398a8875b6 --- /dev/null +++ b/homeassistant/components/zha/core/cluster_handlers/protocol.py @@ -0,0 +1,143 @@ +"""Protocol cluster handlers module for Zigbee Home Automation.""" +from zigpy.zcl.clusters import protocol + +from . import ClusterHandler +from .. import registries + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogInputExtended.cluster_id +) +class AnalogInputExtended(ClusterHandler): + """Analog Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogInputRegular.cluster_id +) +class AnalogInputRegular(ClusterHandler): + """Analog Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogOutputExtended.cluster_id +) +class AnalogOutputExtended(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogOutputRegular.cluster_id +) +class AnalogOutputRegular(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogValueExtended.cluster_id +) +class AnalogValueExtended(ClusterHandler): + """Analog Value Extended edition cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.AnalogValueRegular.cluster_id +) +class AnalogValueRegular(ClusterHandler): + """Analog Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BacnetProtocolTunnel.cluster_id +) +class BacnetProtocolTunnel(ClusterHandler): + """Bacnet Protocol Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryInputExtended.cluster_id +) +class BinaryInputExtended(ClusterHandler): + """Binary Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryInputRegular.cluster_id +) +class BinaryInputRegular(ClusterHandler): + """Binary Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryOutputExtended.cluster_id +) +class BinaryOutputExtended(ClusterHandler): + """Binary Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryOutputRegular.cluster_id +) +class BinaryOutputRegular(ClusterHandler): + """Binary Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryValueExtended.cluster_id +) +class BinaryValueExtended(ClusterHandler): + """Binary Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.BinaryValueRegular.cluster_id +) +class BinaryValueRegular(ClusterHandler): + """Binary Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(protocol.GenericTunnel.cluster_id) +class GenericTunnel(ClusterHandler): + """Generic Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateInputExtended.cluster_id +) +class MultiStateInputExtended(ClusterHandler): + """Multistate Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateInputRegular.cluster_id +) +class MultiStateInputRegular(ClusterHandler): + """Multistate Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateOutputExtended.cluster_id +) +class MultiStateOutputExtended(ClusterHandler): + """Multistate Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateOutputRegular.cluster_id +) +class MultiStateOutputRegular(ClusterHandler): + """Multistate Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateValueExtended.cluster_id +) +class MultiStateValueExtended(ClusterHandler): + """Multistate Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + protocol.MultistateValueRegular.cluster_id +) +class MultiStateValueRegular(ClusterHandler): + """Multistate Value Regular cluster handler.""" diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py similarity index 90% rename from homeassistant/components/zha/core/channels/security.py rename to homeassistant/components/zha/core/cluster_handlers/security.py index 5ecce49267c..7e4951ad672 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -1,4 +1,4 @@ -"""Security channels module for Zigbee Home Automation. +"""Security cluster handlers module for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/integrations/zha/ @@ -15,6 +15,7 @@ from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback +from . import ClusterHandler, ClusterHandlerStatus from .. import registries from ..const import ( SIGNAL_ATTR_UPDATED, @@ -24,10 +25,9 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) -from .base import ChannelStatus, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), @@ -46,13 +46,13 @@ SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(AceCluster.cluster_id) -class IasAce(ZigbeeChannel): - """IAS Ancillary Control Equipment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) +class IasAce(ClusterHandler): + """IAS Ancillary Control Equipment cluster handler.""" - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: - """Initialize IAS Ancillary Control Equipment channel.""" - super().__init__(cluster, ch_pool) + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize IAS Ancillary Control Equipment cluster handler.""" + super().__init__(cluster, endpoint) self.command_map: dict[int, Callable[..., Any]] = { IAS_ACE_ARM: self.arm, IAS_ACE_BYPASS: self._bypass, @@ -105,7 +105,7 @@ class IasAce(ZigbeeChannel): ) zigbee_reply = self.arm_map[mode](code) - self._ch_pool.hass.async_create_task(zigbee_reply) + self._endpoint.device.hass.async_create_task(zigbee_reply) if self.invalid_tries >= self.max_invalid_tries: self.alarm_status = AceCluster.AlarmStatus.Emergency @@ -228,7 +228,7 @@ class IasAce(ZigbeeChannel): AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._ch_pool.hass.async_create_task(response) + self._endpoint.device.hass.async_create_task(response) def _send_panel_status_changed(self) -> None: """Handle the IAS ACE panel status changed command.""" @@ -238,7 +238,7 @@ class IasAce(ZigbeeChannel): AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._ch_pool.hass.async_create_task(response) + self._endpoint.device.hass.async_create_task(response) def _get_bypassed_zone_list(self): """Handle the IAS ACE bypassed zone list command.""" @@ -249,10 +249,10 @@ class IasAce(ZigbeeChannel): """Handle the IAS ACE zone status command.""" -@registries.CHANNEL_ONLY_CLUSTERS.register(security.IasWd.cluster_id) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasWd.cluster_id) -class IasWd(ZigbeeChannel): - """IAS Warning Device channel.""" +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(security.IasWd.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(security.IasWd.cluster_id) +class IasWd(ClusterHandler): + """IAS Warning Device cluster handler.""" @staticmethod def set_bit(destination_value, destination_bit, source_value, source_bit): @@ -332,9 +332,9 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(IasZone.cluster_id) -class IASZoneChannel(ZigbeeChannel): - """Channel for the IASZone Zigbee cluster.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasZone.cluster_id) +class IASZoneClusterHandler(ClusterHandler): + """Cluster handler for the IASZone Zigbee cluster.""" ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} @@ -356,11 +356,11 @@ class IASZoneChannel(ZigbeeChannel): async def async_configure(self): """Configure IAS device.""" await self.get_attribute_value("zone_type", from_cache=False) - if self._ch_pool.skip_configuration: - self.debug("skipping IASZoneChannel configuration") + if self._endpoint.device.skip_configuration: + self.debug("skipping IASZoneClusterHandler configuration") return - self.debug("started IASZoneChannel configuration") + self.debug("started IASZoneClusterHandler configuration") await self.bind() ieee = self.cluster.endpoint.device.application.state.node_info.ieee @@ -384,8 +384,8 @@ class IASZoneChannel(ZigbeeChannel): self.debug("Sending pro-active IAS enroll response") self._cluster.create_catching_task(self._cluster.enroll_response(0, 0)) - self._status = ChannelStatus.CONFIGURED - self.debug("finished IASZoneChannel configuration") + self._status = ClusterHandlerStatus.CONFIGURED + self.debug("finished IASZoneClusterHandler configuration") @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py similarity index 74% rename from homeassistant/components/zha/core/channels/smartenergy.py rename to homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 03d11356f0a..1cb647ea313 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -1,4 +1,4 @@ -"""Smart energy channels module for Zigbee Home Automation.""" +"""Smart energy cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations import enum @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING import zigpy.zcl from zigpy.zcl.clusters import smartenergy +from . import AttrReportConfig, ClusterHandler from .. import registries from ..const import ( REPORT_CONFIG_ASAP, @@ -15,55 +16,60 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import AttrReportConfig, ZigbeeChannel if TYPE_CHECKING: - from . import ChannelPool + from ..endpoint import Endpoint -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Calendar.cluster_id) -class Calendar(ZigbeeChannel): - """Calendar channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Calendar.cluster_id) +class Calendar(ClusterHandler): + """Calendar cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.DeviceManagement.cluster_id) -class DeviceManagement(ZigbeeChannel): - """Device Management channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.DeviceManagement.cluster_id +) +class DeviceManagement(ClusterHandler): + """Device Management cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Drlc.cluster_id) -class Drlc(ZigbeeChannel): - """Demand Response and Load Control channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Drlc.cluster_id) +class Drlc(ClusterHandler): + """Demand Response and Load Control cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.EnergyManagement.cluster_id) -class EnergyManagement(ZigbeeChannel): - """Energy Management channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.EnergyManagement.cluster_id +) +class EnergyManagement(ClusterHandler): + """Energy Management cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Events.cluster_id) -class Events(ZigbeeChannel): - """Event channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Events.cluster_id) +class Events(ClusterHandler): + """Event cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.KeyEstablishment.cluster_id) -class KeyEstablishment(ZigbeeChannel): - """Key Establishment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + smartenergy.KeyEstablishment.cluster_id +) +class KeyEstablishment(ClusterHandler): + """Key Establishment cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.MduPairing.cluster_id) -class MduPairing(ZigbeeChannel): - """Pairing channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.MduPairing.cluster_id) +class MduPairing(ClusterHandler): + """Pairing cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Messaging.cluster_id) -class Messaging(ZigbeeChannel): - """Messaging channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Messaging.cluster_id) +class Messaging(ClusterHandler): + """Messaging cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Metering.cluster_id) -class Metering(ZigbeeChannel): - """Metering channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Metering.cluster_id) +class Metering(ClusterHandler): + """Metering cluster handler.""" REPORT_CONFIG = ( AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), @@ -137,9 +143,9 @@ class Metering(ZigbeeChannel): DEMAND = 0 SUMMATION = 1 - def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize Metering.""" - super().__init__(cluster, ch_pool) + super().__init__(cluster, endpoint) self._format_spec: str | None = None self._summa_format: str | None = None @@ -176,7 +182,7 @@ class Metering(ZigbeeChannel): """Return unit of measurement.""" return self.cluster.get("unit_of_measure") - async def async_initialize_channel_specific(self, from_cache: bool) -> None: + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( @@ -249,16 +255,16 @@ class Metering(ZigbeeChannel): summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Prepayment.cluster_id) -class Prepayment(ZigbeeChannel): - """Prepayment channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Prepayment.cluster_id) +class Prepayment(ClusterHandler): + """Prepayment cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Price.cluster_id) -class Price(ZigbeeChannel): - """Price channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Price.cluster_id) +class Price(ClusterHandler): + """Price cluster handler.""" -@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Tunneling.cluster_id) -class Tunneling(ZigbeeChannel): - """Tunneling channel.""" +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Tunneling.cluster_id) +class Tunneling(ClusterHandler): + """Tunneling cluster handler.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 6423723d326..c90c78243d1 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,39 +64,40 @@ ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] BINDINGS = "bindings" -CHANNEL_ACCELEROMETER = "accelerometer" -CHANNEL_BINARY_INPUT = "binary_input" -CHANNEL_ANALOG_INPUT = "analog_input" -CHANNEL_ANALOG_OUTPUT = "analog_output" -CHANNEL_ATTRIBUTE = "attribute" -CHANNEL_BASIC = "basic" -CHANNEL_COLOR = "light_color" -CHANNEL_COVER = "window_covering" -CHANNEL_DEVICE_TEMPERATURE = "device_temperature" -CHANNEL_DOORLOCK = "door_lock" -CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" -CHANNEL_EVENT_RELAY = "event_relay" -CHANNEL_FAN = "fan" -CHANNEL_HUMIDITY = "humidity" -CHANNEL_SOIL_MOISTURE = "soil_moisture" -CHANNEL_LEAF_WETNESS = "leaf_wetness" -CHANNEL_IAS_ACE = "ias_ace" -CHANNEL_IAS_WD = "ias_wd" -CHANNEL_IDENTIFY = "identify" -CHANNEL_ILLUMINANCE = "illuminance" -CHANNEL_LEVEL = ATTR_LEVEL -CHANNEL_MULTISTATE_INPUT = "multistate_input" -CHANNEL_OCCUPANCY = "occupancy" -CHANNEL_ON_OFF = "on_off" -CHANNEL_POWER_CONFIGURATION = "power" -CHANNEL_PRESSURE = "pressure" -CHANNEL_SHADE = "shade" -CHANNEL_SMARTENERGY_METERING = "smartenergy_metering" -CHANNEL_TEMPERATURE = "temperature" -CHANNEL_THERMOSTAT = "thermostat" -CHANNEL_ZDO = "zdo" -CHANNEL_ZONE = ZONE = "ias_zone" -CHANNEL_INOVELLI = "inovelli_vzm31sn_cluster" +CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" +CLUSTER_HANDLER_BINARY_INPUT = "binary_input" +CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" +CLUSTER_HANDLER_ANALOG_OUTPUT = "analog_output" +CLUSTER_HANDLER_ATTRIBUTE = "attribute" +CLUSTER_HANDLER_BASIC = "basic" +CLUSTER_HANDLER_COLOR = "light_color" +CLUSTER_HANDLER_COVER = "window_covering" +CLUSTER_HANDLER_DEVICE_TEMPERATURE = "device_temperature" +CLUSTER_HANDLER_DOORLOCK = "door_lock" +CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" +CLUSTER_HANDLER_EVENT_RELAY = "event_relay" +CLUSTER_HANDLER_FAN = "fan" +CLUSTER_HANDLER_HUMIDITY = "humidity" +CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy" +CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" +CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" +CLUSTER_HANDLER_IAS_ACE = "ias_ace" +CLUSTER_HANDLER_IAS_WD = "ias_wd" +CLUSTER_HANDLER_IDENTIFY = "identify" +CLUSTER_HANDLER_ILLUMINANCE = "illuminance" +CLUSTER_HANDLER_LEVEL = ATTR_LEVEL +CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" +CLUSTER_HANDLER_OCCUPANCY = "occupancy" +CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_POWER_CONFIGURATION = "power" +CLUSTER_HANDLER_PRESSURE = "pressure" +CLUSTER_HANDLER_SHADE = "shade" +CLUSTER_HANDLER_SMARTENERGY_METERING = "smartenergy_metering" +CLUSTER_HANDLER_TEMPERATURE = "temperature" +CLUSTER_HANDLER_THERMOSTAT = "thermostat" +CLUSTER_HANDLER_ZDO = "zdo" +CLUSTER_HANDLER_ZONE = ZONE = "ias_zone" +CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" CLUSTER_COMMAND_SERVER = "server" CLUSTER_COMMANDS_CLIENT = "client_commands" @@ -151,7 +152,9 @@ CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): cv.positive_int, + vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=2**16 / 10) + ), vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, @@ -330,15 +333,15 @@ REPORT_CONFIG_OP = ( SENSOR_ACCELERATION = "acceleration" SENSOR_BATTERY = "battery" -SENSOR_ELECTRICAL_MEASUREMENT = CHANNEL_ELECTRICAL_MEASUREMENT +SENSOR_ELECTRICAL_MEASUREMENT = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT SENSOR_GENERIC = "generic" -SENSOR_HUMIDITY = CHANNEL_HUMIDITY -SENSOR_ILLUMINANCE = CHANNEL_ILLUMINANCE +SENSOR_HUMIDITY = CLUSTER_HANDLER_HUMIDITY +SENSOR_ILLUMINANCE = CLUSTER_HANDLER_ILLUMINANCE SENSOR_METERING = "metering" -SENSOR_OCCUPANCY = CHANNEL_OCCUPANCY +SENSOR_OCCUPANCY = CLUSTER_HANDLER_OCCUPANCY SENSOR_OPENING = "opening" -SENSOR_PRESSURE = CHANNEL_PRESSURE -SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE +SENSOR_PRESSURE = CLUSTER_HANDLER_PRESSURE +SENSOR_TEMPERATURE = CLUSTER_HANDLER_TEMPERATURE SENSOR_TYPE = "sensor_type" SIGNAL_ADD_ENTITIES = "zha_add_new_entities" @@ -381,12 +384,12 @@ WARNING_DEVICE_SQUAWK_MODE_ARMED = 0 WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" -ZHA_CHANNEL_MSG = "zha_channel_message" -ZHA_CHANNEL_MSG_BIND = "zha_channel_bind" -ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" -ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" -ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" -ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_CLUSTER_HANDLER_MSG = "zha_channel_message" +ZHA_CLUSTER_HANDLER_MSG_BIND = "zha_channel_bind" +ZHA_CLUSTER_HANDLER_MSG_CFG_RPT = "zha_channel_configure_reporting" +ZHA_CLUSTER_HANDLER_MSG_DATA = "zha_channel_msg_data" +ZHA_CLUSTER_HANDLER_CFG_DONE = "zha_channel_cfg_done" +ZHA_CLUSTER_HANDLER_READS_PER_REQ = 5 ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 5cf9322170f..71bfd510bea 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -13,10 +13,10 @@ class DictRegistry(dict[int | str, _TypeT]): def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: """Return decorator to register item with a specific name.""" - def decorator(channel: _TypeT) -> _TypeT: - """Register decorated channel or item.""" - self[name] = channel - return channel + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self[name] = cluster_handler + return cluster_handler return decorator @@ -27,9 +27,9 @@ class SetRegistry(set[int | str]): def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: """Return decorator to register item with a specific name.""" - def decorator(channel: _TypeT) -> _TypeT: - """Register decorated channel or item.""" + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" self.add(name) - return channel + return cluster_handler return decorator diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 9d40314e061..139acb23923 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -23,7 +23,7 @@ from zigpy.zcl.clusters.general import Groups, Identify from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types -from homeassistant.const import ATTR_COMMAND, ATTR_NAME +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import ( @@ -32,7 +32,8 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.event import async_track_time_interval -from . import channels +from . import const +from .cluster_handlers import ClusterHandler, ZDOClusterHandler from .const import ( ATTR_ACTIVE_COORDINATOR, ATTR_ARGS, @@ -81,6 +82,7 @@ from .const import ( UNKNOWN_MODEL, ZHA_OPTIONS, ) +from .endpoint import Endpoint from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: @@ -139,14 +141,26 @@ class ZHADevice(LogMixin): CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ) - keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) - self.unsubs.append( - async_track_time_interval( - self.hass, self._check_available, timedelta(seconds=keep_alive_interval) - ) - ) + self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) + self._power_config_ch: ClusterHandler | None = None + self._identify_ch: ClusterHandler | None = None + self._basic_ch: ClusterHandler | None = None self.status: DeviceStatus = DeviceStatus.CREATED - self._channels = channels.Channels(self) + + self._endpoints: dict[int, Endpoint] = {} + for ep_id, endpoint in zigpy_device.endpoints.items(): + if ep_id != 0: + self._endpoints[ep_id] = Endpoint.new(endpoint, self) + + if not self.is_coordinator: + keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.unsubs.append( + async_track_time_interval( + self.hass, + self._check_available, + timedelta(seconds=keep_alive_interval), + ) + ) @property def device_id(self) -> str: @@ -162,17 +176,6 @@ class ZHADevice(LogMixin): """Return underlying Zigpy device.""" return self._zigpy_device - @property - def channels(self) -> channels.Channels: - """Return ZHA channels.""" - return self._channels - - @channels.setter - def channels(self, value: channels.Channels) -> None: - """Channels setter.""" - assert isinstance(value, channels.Channels) - self._channels = value - @property def name(self) -> str: """Return device name.""" @@ -335,12 +338,62 @@ class ZHADevice(LogMixin): """Set device availability.""" self._available = new_availability + @property + def power_configuration_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._power_config_ch + + @power_configuration_ch.setter + def power_configuration_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._power_config_ch is None: + self._power_config_ch = cluster_handler + + @property + def basic_ch(self) -> ClusterHandler | None: + """Return basic cluster handler.""" + return self._basic_ch + + @basic_ch.setter + def basic_ch(self, cluster_handler: ClusterHandler) -> None: + """Set the basic cluster handler.""" + if self._basic_ch is None: + self._basic_ch = cluster_handler + + @property + def identify_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._identify_ch + + @identify_ch.setter + def identify_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._identify_ch is None: + self._identify_ch = cluster_handler + + @property + def zdo_cluster_handler(self) -> ZDOClusterHandler: + """Return ZDO cluster handler.""" + return self._zdo_handler + + @property + def endpoints(self) -> dict[int, Endpoint]: + """Return the endpoints for this device.""" + return self._endpoints + @property def zigbee_signature(self) -> dict[str, Any]: """Get zigbee signature for this device.""" return { ATTR_NODE_DESCRIPTOR: str(self._zigpy_device.node_desc), - ATTR_ENDPOINTS: self._channels.zigbee_signature, + ATTR_ENDPOINTS: { + signature[0]: signature[1] + for signature in [ + endpoint.zigbee_signature for endpoint in self._endpoints.values() + ] + }, + ATTR_MANUFACTURER: self.manufacturer, + ATTR_MODEL: self.model, } @classmethod @@ -353,11 +406,10 @@ class ZHADevice(LogMixin): ) -> Self: """Create new device.""" zha_dev = cls(hass, zigpy_dev, gateway) - zha_dev.channels = channels.Channels.new(zha_dev) zha_dev.unsubs.append( async_dispatcher_connect( hass, - SIGNAL_UPDATE_DEVICE.format(zha_dev.channels.unique_id), + SIGNAL_UPDATE_DEVICE.format(str(zha_dev.ieee)), zha_dev.async_update_sw_build_id, ) ) @@ -393,7 +445,7 @@ class ZHADevice(LogMixin): if ( self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS or self.manufacturer == "LUMI" - or not self._channels.pools + or not self._endpoints ): self.debug( ( @@ -410,14 +462,13 @@ class ZHADevice(LogMixin): "Attempting to checkin with device - missed checkins: %s", self._checkins_missed_count, ) - try: - pool = self._channels.pools[0] - basic_ch = pool.all_channels[f"{pool.id}:0x0000"] - except KeyError: + if not self.basic_ch: self.debug("does not have a mandatory basic cluster") self.update_available(False) return - res = await basic_ch.get_attribute_value(ATTR_MANUFACTURER, from_cache=False) + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) if res is not None: self._checkins_missed_count = 0 @@ -435,22 +486,35 @@ class ZHADevice(LogMixin): availability_changed = self.available ^ available self.available = available if availability_changed and available: - # reinit channels then signal entities + # reinit cluster handlers then signal entities self.debug( "Device availability changed and device became available," - " reinitializing channels" + " reinitializing cluster handlers" ) self.hass.async_create_task(self._async_became_available()) return if availability_changed and not available: self.debug("Device availability changed and device became unavailable") - self._channels.zha_send_event( + self.zha_send_event( { "device_event_type": "device_offline", }, ) async_dispatcher_send(self.hass, f"{self._available_signal}_entity") + @callback + def zha_send_event(self, event_data: dict[str, str | int]) -> None: + """Relay events to hass.""" + self.hass.bus.async_fire( + const.ZHA_EVENT, + { + const.ATTR_DEVICE_IEEE: str(self.ieee), + const.ATTR_UNIQUE_ID: str(self.ieee), + ATTR_DEVICE_ID: self.device_id, + **event_data, + }, + ) + async def _async_became_available(self) -> None: """Update device availability and signal entities.""" await self.async_initialize(False) @@ -489,23 +553,41 @@ class ZHADevice(LogMixin): True, ) self.debug("started configuration") - await self._channels.async_configure() + await self._zdo_handler.async_configure() + self._zdo_handler.debug("'async_configure' stage succeeded") + await asyncio.gather( + *(endpoint.async_configure() for endpoint in self._endpoints.values()) + ) + async_dispatcher_send( + self.hass, + const.ZHA_CLUSTER_HANDLER_MSG, + { + const.ATTR_TYPE: const.ZHA_CLUSTER_HANDLER_CFG_DONE, + }, + ) self.debug("completed configuration") if ( should_identify - and self._channels.identify_ch is not None + and self.identify_ch is not None and not self.skip_configuration ): - await self._channels.identify_ch.trigger_effect( + await self.identify_ch.trigger_effect( effect_id=Identify.EffectIdentifier.Okay, effect_variant=Identify.EffectVariant.Default, ) async def async_initialize(self, from_cache: bool = False) -> None: - """Initialize channels.""" + """Initialize cluster handlers.""" self.debug("started initialization") - await self._channels.async_initialize(from_cache) + await self._zdo_handler.async_initialize(from_cache) + self._zdo_handler.debug("'async_initialize' stage succeeded") + await asyncio.gather( + *( + endpoint.async_initialize(from_cache) + for endpoint in self._endpoints.values() + ) + ) self.debug("power source: %s", self.power_source) self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index d256b98cfb1..e8b6f5f8304 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -33,12 +33,27 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, siren, switch, ) -from .channels import base + +# importing cluster handlers updates registries +from .cluster_handlers import ( # noqa: F401 pylint: disable=unused-import, + ClusterHandler, + closures, + general, + homeautomation, + hvac, + lighting, + lightlink, + manufacturerspecific, + measurement, + protocol, + security, + smartenergy, +) if TYPE_CHECKING: from ..entity import ZhaEntity - from .channels import ChannelPool from .device import ZHADevice + from .endpoint import Endpoint from .gateway import ZHAGateway from .group import ZHAGroup @@ -51,7 +66,7 @@ async def async_add_entities( entities: list[ tuple[ type[ZhaEntity], - tuple[str, ZHADevice, list[base.ZigbeeChannel]], + tuple[str, ZHADevice, list[ClusterHandler]], ] ], ) -> None: @@ -65,49 +80,56 @@ async def async_add_entities( class ProbeEndpoint: - """All discovered channels and entities of an endpoint.""" + """All discovered cluster handlers and entities of an endpoint.""" def __init__(self) -> None: """Initialize instance.""" self._device_configs: ConfigType = {} @callback - def discover_entities(self, channel_pool: ChannelPool) -> None: + def discover_entities(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" - self.discover_by_device_type(channel_pool) - self.discover_multi_entities(channel_pool) - self.discover_by_cluster_id(channel_pool) - self.discover_multi_entities(channel_pool, config_diagnostic_entities=True) + _LOGGER.debug( + "Discovering entities for endpoint: %s-%s", + str(endpoint.device.ieee), + endpoint.id, + ) + self.discover_by_device_type(endpoint) + self.discover_multi_entities(endpoint) + self.discover_by_cluster_id(endpoint) + self.discover_multi_entities(endpoint, config_diagnostic_entities=True) zha_regs.ZHA_ENTITIES.clean_up() @callback - def discover_by_device_type(self, channel_pool: ChannelPool) -> None: + def discover_by_device_type(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" - unique_id = channel_pool.unique_id + unique_id = endpoint.unique_id - component: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) - if component is None: - ep_profile_id = channel_pool.endpoint.profile_id - ep_device_type = channel_pool.endpoint.device_type - component = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + platform: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) + if platform is None: + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type + platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) - if component and component in zha_const.PLATFORMS: - channels = channel_pool.unclaimed_channels() - entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, - channel_pool.manufacturer, - channel_pool.model, - channels, - channel_pool.quirk_class, + if platform and platform in zha_const.PLATFORMS: + cluster_handlers = endpoint.unclaimed_cluster_handlers() + platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handlers, + endpoint.device.quirk_class, ) - if entity_class is None: + if platform_entity_class is None: return - channel_pool.claim_channels(claimed) - channel_pool.async_new_entity(component, entity_class, unique_id, claimed) + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity( + platform, platform_entity_class, unique_id, claimed + ) @callback - def discover_by_cluster_id(self, channel_pool: ChannelPool) -> None: + def discover_by_cluster_id(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() @@ -116,124 +138,127 @@ class ProbeEndpoint: for cluster_class, match in items if not isinstance(cluster_class, int) } - remaining_channels = channel_pool.unclaimed_channels() - for channel in remaining_channels: - if channel.cluster.cluster_id in zha_regs.CHANNEL_ONLY_CLUSTERS: - channel_pool.claim_channels([channel]) + remaining_cluster_handlers = endpoint.unclaimed_cluster_handlers() + for cluster_handler in remaining_cluster_handlers: + if ( + cluster_handler.cluster.cluster_id + in zha_regs.CLUSTER_HANDLER_ONLY_CLUSTERS + ): + endpoint.claim_cluster_handlers([cluster_handler]) continue - component = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( - channel.cluster.cluster_id + platform = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + cluster_handler.cluster.cluster_id ) - if component is None: + if platform is None: for cluster_class, match in single_input_clusters.items(): - if isinstance(channel.cluster, cluster_class): - component = match + if isinstance(cluster_handler.cluster, cluster_class): + platform = match break - self.probe_single_cluster(component, channel, channel_pool) + self.probe_single_cluster(platform, cluster_handler, endpoint) # until we can get rid of registries - self.handle_on_off_output_cluster_exception(channel_pool) + self.handle_on_off_output_cluster_exception(endpoint) @staticmethod def probe_single_cluster( - component: Platform | None, - channel: base.ZigbeeChannel, - ep_channels: ChannelPool, + platform: Platform | None, + cluster_handler: ClusterHandler, + endpoint: Endpoint, ) -> None: """Probe specified cluster for specific component.""" - if component is None or component not in zha_const.PLATFORMS: + if platform is None or platform not in zha_const.PLATFORMS: return - channel_list = [channel] - unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" + cluster_handler_list = [cluster_handler] + unique_id = f"{endpoint.unique_id}-{cluster_handler.cluster.cluster_id}" entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, - ep_channels.manufacturer, - ep_channels.model, - channel_list, - ep_channels.quirk_class, + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handler_list, + endpoint.device.quirk_class, ) if entity_class is None: return - ep_channels.claim_channels(claimed) - ep_channels.async_new_entity(component, entity_class, unique_id, claimed) + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity(platform, entity_class, unique_id, claimed) - def handle_on_off_output_cluster_exception(self, ep_channels: ChannelPool) -> None: + def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: """Process output clusters of the endpoint.""" - profile_id = ep_channels.endpoint.profile_id - device_type = ep_channels.endpoint.device_type + profile_id = endpoint.zigpy_endpoint.profile_id + device_type = endpoint.zigpy_endpoint.device_type if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): return - for cluster_id, cluster in ep_channels.endpoint.out_clusters.items(): - component = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( + for cluster_id, cluster in endpoint.zigpy_endpoint.out_clusters.items(): + platform = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( cluster.cluster_id ) - if component is None: + if platform is None: continue - channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.ZigbeeChannel + cluster_handler_class = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, ClusterHandler ) - channel = channel_class(cluster, ep_channels) - self.probe_single_cluster(component, channel, ep_channels) + cluster_handler = cluster_handler_class(cluster, endpoint) + self.probe_single_cluster(platform, cluster_handler, endpoint) @staticmethod @callback def discover_multi_entities( - channel_pool: ChannelPool, + endpoint: Endpoint, config_diagnostic_entities: bool = False, ) -> None: """Process an endpoint on and discover multiple entities.""" - ep_profile_id = channel_pool.endpoint.profile_id - ep_device_type = channel_pool.endpoint.device_type + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if config_diagnostic_entities: matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( - channel_pool.manufacturer, - channel_pool.model, - list(channel_pool.all_channels.values()), - channel_pool.quirk_class, + endpoint.device.manufacturer, + endpoint.device.model, + list(endpoint.all_cluster_handlers.values()), + endpoint.device.quirk_class, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( - channel_pool.manufacturer, - channel_pool.model, - channel_pool.unclaimed_channels(), - channel_pool.quirk_class, + endpoint.device.manufacturer, + endpoint.device.model, + endpoint.unclaimed_cluster_handlers(), + endpoint.device.quirk_class, ) - channel_pool.claim_channels(claimed) - for component, ent_n_chan_list in matches.items(): - for entity_and_channel in ent_n_chan_list: + endpoint.claim_cluster_handlers(claimed) + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: _LOGGER.debug( "'%s' component -> '%s' using %s", - component, - entity_and_channel.entity_class.__name__, - [ch.name for ch in entity_and_channel.claimed_channel], + platform, + entity_and_handler.entity_class.__name__, + [ch.name for ch in entity_and_handler.claimed_cluster_handlers], ) - for component, ent_n_chan_list in matches.items(): - for entity_and_channel in ent_n_chan_list: - if component == cmpt_by_dev_type: + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: + if platform == cmpt_by_dev_type: # for well known device types, like thermostats we'll take only 1st class - channel_pool.async_new_entity( - component, - entity_and_channel.entity_class, - channel_pool.unique_id, - entity_and_channel.claimed_channel, + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + endpoint.unique_id, + entity_and_handler.claimed_cluster_handlers, ) break - first_ch = entity_and_channel.claimed_channel[0] - channel_pool.async_new_entity( - component, - entity_and_channel.entity_class, - f"{channel_pool.unique_id}-{first_ch.cluster.cluster_id}", - entity_and_channel.claimed_channel, + first_ch = entity_and_handler.claimed_cluster_handlers[0] + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + f"{endpoint.unique_id}-{first_ch.cluster.cluster_id}", + entity_and_handler.claimed_cluster_handlers, ) def initialize(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py new file mode 100644 index 00000000000..d134c033ed7 --- /dev/null +++ b/homeassistant/components/zha/core/endpoint.py @@ -0,0 +1,227 @@ +"""Representation of a Zigbee endpoint for zha.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any, Final, TypeVar + +import zigpy +from zigpy.typing import EndpointType as ZigpyEndpointType + +from homeassistant.const import Platform +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import const, discovery, registries +from .cluster_handlers import ClusterHandler +from .cluster_handlers.general import MultistateInput + +if TYPE_CHECKING: + from .cluster_handlers import ClientClusterHandler + from .device import ZHADevice + +ATTR_DEVICE_TYPE: Final[str] = "device_type" +ATTR_PROFILE_ID: Final[str] = "profile_id" +ATTR_IN_CLUSTERS: Final[str] = "input_clusters" +ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" + +_LOGGER = logging.getLogger(__name__) +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name + + +class Endpoint: + """Endpoint for a zha device.""" + + def __init__(self, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> None: + """Initialize instance.""" + assert zigpy_endpoint is not None + assert device is not None + self._zigpy_endpoint: ZigpyEndpointType = zigpy_endpoint + self._device: ZHADevice = device + self._all_cluster_handlers: dict[str, ClusterHandler] = {} + self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} + self._client_cluster_handlers: dict[str, ClientClusterHandler] = {} + self._unique_id: str = f"{str(device.ieee)}-{zigpy_endpoint.endpoint_id}" + + @property + def device(self) -> ZHADevice: + """Return the device this endpoint belongs to.""" + return self._device + + @property + def all_cluster_handlers(self) -> dict[str, ClusterHandler]: + """All server cluster handlers of an endpoint.""" + return self._all_cluster_handlers + + @property + def claimed_cluster_handlers(self) -> dict[str, ClusterHandler]: + """Cluster handlers in use.""" + return self._claimed_cluster_handlers + + @property + def client_cluster_handlers(self) -> dict[str, ClientClusterHandler]: + """Return a dict of client cluster handlers.""" + return self._client_cluster_handlers + + @property + def zigpy_endpoint(self) -> ZigpyEndpointType: + """Return endpoint of zigpy device.""" + return self._zigpy_endpoint + + @property + def id(self) -> int: + """Return endpoint id.""" + return self._zigpy_endpoint.endpoint_id + + @property + def unique_id(self) -> str: + """Return the unique id for this endpoint.""" + return self._unique_id + + @property + def zigbee_signature(self) -> tuple[int, dict[str, Any]]: + """Get the zigbee signature for the endpoint this pool represents.""" + return ( + self.id, + { + ATTR_PROFILE_ID: f"0x{self._zigpy_endpoint.profile_id:04x}" + if self._zigpy_endpoint.profile_id is not None + else "", + ATTR_DEVICE_TYPE: f"0x{self._zigpy_endpoint.device_type:04x}" + if self._zigpy_endpoint.device_type is not None + else "", + ATTR_IN_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.in_clusters) + ], + ATTR_OUT_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.out_clusters) + ], + }, + ) + + @classmethod + def new(cls, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> Endpoint: + """Create new endpoint and populate cluster handlers.""" + endpoint = cls(zigpy_endpoint, device) + endpoint.add_all_cluster_handlers() + endpoint.add_client_cluster_handlers() + if not device.is_coordinator: + discovery.PROBE.discover_entities(endpoint) + return endpoint + + def add_all_cluster_handlers(self) -> None: + """Create and add cluster handlers for all input clusters.""" + for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, ClusterHandler + ) + + # Allow cluster handler to filter out bad matches + if not cluster_handler_class.matches(cluster, self): + cluster_handler_class = ClusterHandler + + _LOGGER.info( + "Creating cluster handler for cluster id: %s class: %s", + cluster_id, + cluster_handler_class, + ) + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if ( + hasattr(cluster, "ep_attribute") + and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id + and cluster.ep_attribute == "multistate_input" + ): + cluster_handler_class = MultistateInput + # end of ugly hack + cluster_handler = cluster_handler_class(cluster, self) + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: + self._device.power_configuration_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: + self._device.identify_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_BASIC: + self._device.basic_ch = cluster_handler + self._all_cluster_handlers[cluster_handler.id] = cluster_handler + + def add_client_cluster_handlers(self) -> None: + """Create client cluster handlers for all output clusters if in the registry.""" + for ( + cluster_id, + cluster_handler_class, + ) in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.items(): + cluster = self.zigpy_endpoint.out_clusters.get(cluster_id) + if cluster is not None: + cluster_handler = cluster_handler_class(cluster, self) + self.client_cluster_handlers[cluster_handler.id] = cluster_handler + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed cluster handlers.""" + await self._execute_handler_tasks("async_initialize", from_cache) + + async def async_configure(self) -> None: + """Configure claimed cluster handlers.""" + await self._execute_handler_tasks("async_configure") + + async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: + """Add a throttled cluster handler task and swallow exceptions.""" + cluster_handlers = [ + *self.claimed_cluster_handlers.values(), + *self.client_cluster_handlers.values(), + ] + tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers] + results = await asyncio.gather(*tasks, return_exceptions=True) + for cluster_handler, outcome in zip(cluster_handlers, results): + if isinstance(outcome, Exception): + cluster_handler.warning( + "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome + ) + continue + cluster_handler.debug("'%s' stage succeeded", func_name) + + def async_new_entity( + self, + platform: Platform | str, + entity_class: CALLABLE_T, + unique_id: str, + cluster_handlers: list[ClusterHandler], + ) -> None: + """Create a new entity.""" + from .device import DeviceStatus # pylint: disable=import-outside-toplevel + + if self.device.status == DeviceStatus.INITIALIZED: + return + + self.device.hass.data[const.DATA_ZHA][platform].append( + (entity_class, (unique_id, self.device, cluster_handlers)) + ) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.device.hass, signal, *args) + + def send_event(self, signal: dict[str, Any]) -> None: + """Broadcast an event from this endpoint.""" + self.device.zha_send_event( + { + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **signal, + } + ) + + def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None: + """Claim cluster handlers.""" + self.claimed_cluster_handlers.update({ch.id: ch for ch in cluster_handlers}) + + def unclaimed_cluster_handlers(self) -> list[ClusterHandler]: + """Return a list of available (unclaimed) cluster handlers.""" + claimed = set(self.claimed_cluster_handlers) + available = set(self.all_cluster_handlers) + return [ + self.all_cluster_handlers[cluster_id] + for cluster_id in (available - claimed) + ] diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 8858ea69590..02c16930d53 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -93,7 +93,7 @@ if TYPE_CHECKING: from logging import Filter, LogRecord from ..entity import ZhaEntity - from .channels.base import ZigbeeChannel + from .cluster_handlers import ClusterHandler _LogFilterType = Filter | Callable[[LogRecord], bool] @@ -105,7 +105,7 @@ class EntityReference(NamedTuple): reference_id: str zha_device: ZHADevice - cluster_channels: dict[str, ZigbeeChannel] + cluster_handlers: dict[str, ClusterHandler] device_info: DeviceInfo remove_future: asyncio.Future[Any] @@ -520,7 +520,7 @@ class ZHAGateway: ieee: EUI64, reference_id: str, zha_device: ZHADevice, - cluster_channels: dict[str, ZigbeeChannel], + cluster_handlers: dict[str, ClusterHandler], device_info: DeviceInfo, remove_future: asyncio.Future[Any], ): @@ -529,7 +529,7 @@ class ZHAGateway: EntityReference( reference_id=reference_id, zha_device=zha_device, - cluster_channels=cluster_channels, + cluster_handlers=cluster_handlers, device_info=device_info, remove_future=remove_future, ) @@ -733,6 +733,8 @@ class ZHAGateway: _LOGGER.debug("Shutting down ZHA ControllerApplication") for unsubscribe in self._unsubs: unsubscribe() + for device in self.devices.values(): + device.async_cleanup_handles() await self.application_controller.shutdown() def handle_message( diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 82997dc2a53..ebea2f4ac41 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -89,7 +89,7 @@ class ZHAGroupMember(LogMixin): entity_ref.reference_id, )._asdict() for entity_ref in zha_device_registry.get(self.device.ieee) - if list(entity_ref.cluster_channels.values())[ + if list(entity_ref.cluster_handlers.values())[ 0 ].cluster.endpoint.endpoint_id == self.endpoint_id diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 526af1a7e49..ac7c15d3ecd 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -309,19 +309,19 @@ class LogMixin: def debug(self, msg, *args, **kwargs): """Debug level log.""" - return self.log(logging.DEBUG, msg, *args) + return self.log(logging.DEBUG, msg, *args, **kwargs) def info(self, msg, *args, **kwargs): """Info level log.""" - return self.log(logging.INFO, msg, *args) + return self.log(logging.INFO, msg, *args, **kwargs) def warning(self, msg, *args, **kwargs): """Warning method log.""" - return self.log(logging.WARNING, msg, *args) + return self.log(logging.WARNING, msg, *args, **kwargs) def error(self, msg, *args, **kwargs): """Error level log.""" - return self.log(logging.ERROR, msg, *args) + return self.log(logging.ERROR, msg, *args, **kwargs) def retryable_req( @@ -336,17 +336,17 @@ def retryable_req( def decorator(func): @functools.wraps(func) - async def wrapper(channel, *args, **kwargs): + async def wrapper(cluster_handler, *args, **kwargs): exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) try_count, errors = 1, [] for delay in itertools.chain(delays, [None]): try: - return await func(channel, *args, **kwargs) + return await func(cluster_handler, *args, **kwargs) except exceptions as ex: errors.append(ex) if delay: delay = uniform(delay * 0.75, delay * 1.25) - channel.debug( + cluster_handler.debug( "%s: retryable request #%d failed: %s. Retrying in %ss", func.__name__, try_count, @@ -356,7 +356,7 @@ def retryable_req( try_count += 1 await asyncio.sleep(delay) else: - channel.warning( + cluster_handler.warning( "%s: all attempts have failed: %s", func.__name__, errors ) if raise_: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index a7504ae7a96..0c7369f15e7 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -14,13 +14,11 @@ from zigpy.types.named import EUI64 from homeassistant.const import Platform -# importing channels updates registries -from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import DictRegistry, SetRegistry if TYPE_CHECKING: from ..entity import ZhaEntity, ZhaGroupEntity - from .channels.base import ClientChannel, ZigbeeChannel + from .cluster_handlers import ClientClusterHandler, ClusterHandler _ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) @@ -75,7 +73,6 @@ SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { } BINDABLE_CLUSTERS = SetRegistry() -CHANNEL_ONLY_CLUSTERS = SetRegistry() DEVICE_CLASS = { zigpy.profiles.zha.PROFILE_ID: { @@ -108,8 +105,11 @@ DEVICE_CLASS = { } DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) -CLIENT_CHANNELS_REGISTRY: DictRegistry[type[ClientChannel]] = DictRegistry() -ZIGBEE_CHANNEL_REGISTRY: DictRegistry[type[ZigbeeChannel]] = DictRegistry() +CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() +CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ + type[ClientClusterHandler] +] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() def set_or_callable(value) -> frozenset[str] | Callable: @@ -129,9 +129,9 @@ def _get_empty_frozenset() -> frozenset[str]: @attr.s(frozen=True) class MatchRule: - """Match a ZHA Entity to a channel name or generic id.""" + """Match a ZHA Entity to a cluster handler name or generic id.""" - channel_names: frozenset[str] = attr.ib( + cluster_handler_names: frozenset[str] = attr.ib( factory=frozenset, converter=set_or_callable ) generic_ids: frozenset[str] = attr.ib(factory=frozenset, converter=set_or_callable) @@ -141,7 +141,7 @@ class MatchRule: models: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) - aux_channels: frozenset[str] | Callable = attr.ib( + aux_cluster_handlers: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) quirk_classes: frozenset[str] | Callable = attr.ib( @@ -157,9 +157,9 @@ class MatchRule: and have a priority over manufacturer matching rules and rules matching a single model/manufacturer get a better priority over rules matching multiple models/manufacturers. And any model or manufacturers matching rules get better - priority over rules matching only channels. - But in case of a channel name/channel id matching, we give rules matching - multiple channels a better priority over rules matching a single channel. + priority over rules matching only cluster handlers. + But in case of a cluster handler name/cluster handler id matching, we give rules matching + multiple cluster handlers a better priority over rules matching a single cluster handler. """ weight = 0 if self.quirk_classes: @@ -175,51 +175,57 @@ class MatchRule: 1 if callable(self.manufacturers) else len(self.manufacturers) ) - weight += 10 * len(self.channel_names) + weight += 10 * len(self.cluster_handler_names) weight += 5 * len(self.generic_ids) - if isinstance(self.aux_channels, frozenset): - weight += 1 * len(self.aux_channels) + if isinstance(self.aux_cluster_handlers, frozenset): + weight += 1 * len(self.aux_cluster_handlers) return weight - def claim_channels(self, channel_pool: list[ZigbeeChannel]) -> list[ZigbeeChannel]: - """Return a list of channels this rule matches + aux channels.""" + def claim_cluster_handlers( + self, cluster_handlers: list[ClusterHandler] + ) -> list[ClusterHandler]: + """Return a list of cluster handlers this rule matches + aux cluster handlers.""" claimed = [] - if isinstance(self.channel_names, frozenset): - claimed.extend([ch for ch in channel_pool if ch.name in self.channel_names]) + if isinstance(self.cluster_handler_names, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.cluster_handler_names] + ) if isinstance(self.generic_ids, frozenset): claimed.extend( - [ch for ch in channel_pool if ch.generic_id in self.generic_ids] + [ch for ch in cluster_handlers if ch.generic_id in self.generic_ids] + ) + if isinstance(self.aux_cluster_handlers, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.aux_cluster_handlers] ) - if isinstance(self.aux_channels, frozenset): - claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) return claimed def strict_matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, channels, quirk_class)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) def loose_matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, channels, quirk_class)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) def _matched( - self, manufacturer: str, model: str, channels: list, quirk_class: str + self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): return [False] matches = [] - if self.channel_names: - channel_names = {ch.name for ch in channels} - matches.append(self.channel_names.issubset(channel_names)) + if self.cluster_handler_names: + cluster_handler_names = {ch.name for ch in cluster_handlers} + matches.append(self.cluster_handler_names.issubset(cluster_handler_names)) if self.generic_ids: - all_generic_ids = {ch.generic_id for ch in channels} + all_generic_ids = {ch.generic_id for ch in cluster_handlers} matches.append(self.generic_ids.issubset(all_generic_ids)) if self.manufacturers: @@ -244,15 +250,15 @@ class MatchRule: @dataclasses.dataclass -class EntityClassAndChannels: - """Container for entity class and corresponding channels.""" +class EntityClassAndClusterHandlers: + """Container for entity class and corresponding cluster handlers.""" entity_class: type[ZhaEntity] - claimed_channel: list[ZigbeeChannel] + claimed_cluster_handlers: list[ClusterHandler] class ZHAEntityRegistry: - """Channel to ZHA Entity mapping.""" + """Cluster handler to ZHA Entity mapping.""" def __init__(self) -> None: """Initialize Registry instance.""" @@ -279,15 +285,15 @@ class ZHAEntityRegistry: component: str, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, default: type[ZhaEntity] | None = None, - ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: - """Match a ZHA Channels to a ZHA Entity class.""" + ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: + """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): + claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed return default, [] @@ -296,21 +302,27 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: - """Match ZHA Channels to potentially multiple ZHA Entity classes.""" - result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ZigbeeChannel] = set() + ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + str, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_class + ): + claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: - ent_n_channels = EntityClassAndChannels(ent_class, claimed) - result[component].append(ent_n_channels) + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break @@ -321,12 +333,14 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: - """Match ZHA Channels to potentially multiple ZHA Entity classes.""" - result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ZigbeeChannel] = set() + ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + str, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() for ( component, stop_match_groups, @@ -334,11 +348,15 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels, quirk_class): - claimed = match.claim_channels(channels) + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_class + ): + claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: - ent_n_channels = EntityClassAndChannels(ent_class, claimed) - result[component].append(ent_n_channels) + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break @@ -352,21 +370,21 @@ class ZHAEntityRegistry: def strict_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -383,22 +401,22 @@ class ZHAEntityRegistry: def multipass_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -407,7 +425,7 @@ class ZHAEntityRegistry: All non empty fields of a match rule must match. """ - # group the rules by channels + # group the rules by cluster handlers self._multi_entity_registry[component][stop_on_match_group][rule].append( zha_entity ) @@ -418,22 +436,22 @@ class ZHAEntityRegistry: def config_diagnostic_match( self, component: str, - channel_names: set[str] | str | None = None, + cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, - aux_channels: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( - channel_names, + cluster_handler_names, generic_ids, manufacturers, models, - aux_channels, + aux_cluster_handlers, quirk_classes, ) @@ -442,7 +460,7 @@ class ZHAEntityRegistry: All non-empty fields of a match rule must match. """ - # group the rules by channels + # group the rules by cluster handlers self._config_diagnostic_entity_registry[component][stop_on_match_group][ rule ].append(zha_entity) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f6c67e6981d..fce37904126 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -28,10 +28,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_COVER, - CHANNEL_LEVEL, - CHANNEL_ON_OFF, - CHANNEL_SHADE, + CLUSTER_HANDLER_COVER, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -41,7 +41,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -67,21 +67,23 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@MULTI_MATCH(channel_names=CHANNEL_COVER) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Cover" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._cover_channel = self.cluster_channels.get(CHANNEL_COVER) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) self._current_position = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._cover_channel, SIGNAL_ATTR_UPDATED, self.async_set_position + self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_position ) @callback @@ -118,7 +120,7 @@ class ZhaCover(ZhaEntity, CoverEntity): @callback def async_set_position(self, attr_id, attr_name, value): - """Handle position update from channel.""" + """Handle position update from cluster handler.""" _LOGGER.debug("setting position: %s", value) self._current_position = 100 - value if self._current_position == 0: @@ -129,27 +131,27 @@ class ZhaCover(ZhaEntity, CoverEntity): @callback def async_update_state(self, state): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" _LOGGER.debug("state=%s", state) self._state = state self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" - res = await self._cover_channel.up_open() + res = await self._cover_cluster_handler.up_open() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" - res = await self._cover_channel.down_close() + res = await self._cover_cluster_handler.down_close() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] - res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) + res = await self._cover_cluster_handler.go_to_lift_percentage(100 - new_pos) if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state( STATE_CLOSING if new_pos < self._current_position else STATE_OPENING @@ -157,7 +159,7 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" - res = await self._cover_channel.stop() + res = await self._cover_cluster_handler.stop() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() @@ -170,8 +172,8 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_get_state(self, from_cache=True): """Fetch the current state.""" _LOGGER.debug("polling current state") - if self._cover_channel: - pos = await self._cover_channel.get_attribute_value( + if self._cover_cluster_handler: + pos = await self._cover_cluster_handler.get_attribute_value( "current_position_lift_percentage", from_cache=from_cache ) _LOGGER.debug("read pos=%s", pos) @@ -186,23 +188,30 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = None -@MULTI_MATCH(channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF, CHANNEL_SHADE}) +@MULTI_MATCH( + cluster_handler_names={ + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, + } +) class Shade(ZhaEntity, CoverEntity): """ZHA Shade.""" _attr_device_class = CoverDeviceClass.SHADE + _attr_name: str = "Shade" def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs, ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._level_channel = self.cluster_channels[CHANNEL_LEVEL] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._level_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_LEVEL] self._position: int | None = None self._is_open: bool | None = None @@ -225,10 +234,12 @@ class Shade(ZhaEntity, CoverEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_open_closed + self._on_off_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_open_closed, ) self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.async_set_level + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.async_set_level ) @callback @@ -253,7 +264,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" - res = await self._on_off_channel.on() + res = await self._on_off_cluster_handler.on() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't open cover: %s", res) return @@ -263,7 +274,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" - res = await self._on_off_channel.off() + res = await self._on_off_cluster_handler.off() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't open cover: %s", res) return @@ -274,7 +285,7 @@ class Shade(ZhaEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] - res = await self._level_channel.move_to_level_with_on_off( + res = await self._level_cluster_handler.move_to_level_with_on_off( new_pos * 255 / 100, 1 ) @@ -287,26 +298,31 @@ class Shade(ZhaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - res = await self._level_channel.stop() + res = await self._level_cluster_handler.stop() if isinstance(res, Exception) or res[1] != Status.SUCCESS: self.debug("couldn't stop cover: %s", res) return @MULTI_MATCH( - channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF}, manufacturers="Keen Home Inc" + cluster_handler_names={CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF}, + manufacturers="Keen Home Inc", ) class KeenVent(Shade): """Keen vent cover.""" + _attr_name: str = "Keen vent" + _attr_device_class = CoverDeviceClass.DAMPER async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" position = self._position or 100 tasks = [ - self._level_channel.move_to_level_with_on_off(position * 255 / 100, 1), - self._on_off_channel.on(), + self._level_cluster_handler.move_to_level_with_on_off( + position * 255 / 100, 1 + ), + self._on_off_cluster_handler.on(), ] results = await asyncio.gather(*tasks, return_exceptions=True) if any(isinstance(result, Exception) for result in results): diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 25a01f45baa..d393cfb1471 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,8 +12,11 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -from .core.channels.manufacturerspecific import AllLEDEffectType, SingleLEDEffectType -from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI +from .core.cluster_handlers.manufacturerspecific import ( + AllLEDEffectType, + SingleLEDEffectType, +) +from .core.const import CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI from .core.helpers import async_get_zha_device from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN @@ -25,7 +28,7 @@ ATTR_DATA = "data" ATTR_IEEE = "ieee" CONF_ZHA_ACTION_TYPE = "zha_action_type" ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" -ZHA_ACTION_TYPE_CHANNEL_COMMAND = "channel_command" +ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command" INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect" INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect" @@ -67,11 +70,11 @@ ACTION_SCHEMA = vol.Any( ) DEVICE_ACTIONS = { - CHANNEL_IAS_WD: [ + CLUSTER_HANDLER_IAS_WD: [ {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, ], - CHANNEL_INOVELLI: [ + CLUSTER_HANDLER_INOVELLI: [ {CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, ], @@ -80,8 +83,8 @@ DEVICE_ACTIONS = { DEVICE_ACTION_TYPES = { ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL, ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL, - INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, - INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, + INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, + INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, } DEVICE_ACTION_SCHEMAS = { @@ -109,9 +112,9 @@ SERVICE_NAMES = { ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, } -CHANNEL_MAPPINGS = { - INOVELLI_ALL_LED_EFFECT: CHANNEL_INOVELLI, - INOVELLI_INDIVIDUAL_LED_EFFECT: CHANNEL_INOVELLI, +CLUSTER_HANDLER_MAPPINGS = { + INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, + INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, } @@ -144,16 +147,16 @@ async def async_get_actions( zha_device = async_get_zha_device(hass, device_id) except (KeyError, AttributeError): return [] - cluster_channels = [ + cluster_handlers = [ ch.name - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() ] actions = [ action - for channel, channel_actions in DEVICE_ACTIONS.items() - for action in channel_actions - if channel in cluster_channels + for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items() + for action in cluster_handler_actions + if cluster_handler in cluster_handlers ] for action in actions: action[CONF_DEVICE_ID] = device_id @@ -188,42 +191,42 @@ async def _execute_service_based_action( ) -async def _execute_channel_command_based_action( +async def _execute_cluster_handler_command_based_action( hass: HomeAssistant, config: dict[str, Any], variables: TemplateVarsType, context: Context | None, ) -> None: action_type = config[CONF_TYPE] - channel_name = CHANNEL_MAPPINGS[action_type] + cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type] try: zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) except (KeyError, AttributeError): return - action_channel = None - for pool in zha_device.channels.pools: - for channel in pool.all_channels.values(): - if channel.name == channel_name: - action_channel = channel + action_cluster_handler = None + for endpoint in zha_device.endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): + if cluster_handler.name == cluster_handler_name: + action_cluster_handler = cluster_handler break - if action_channel is None: + if action_cluster_handler is None: raise InvalidDeviceAutomationConfig( - f"Unable to execute channel action - channel: {channel_name} action:" + f"Unable to execute cluster handler action - cluster handler: {cluster_handler_name} action:" f" {action_type}" ) - if not hasattr(action_channel, action_type): + if not hasattr(action_cluster_handler, action_type): raise InvalidDeviceAutomationConfig( - f"Unable to execute channel action - channel: {channel_name} action:" + f"Unable to execute cluster handler - cluster handler: {cluster_handler_name} action:" f" {action_type}" ) - await getattr(action_channel, action_type)(**config) + await getattr(action_cluster_handler, action_type)(**config) ZHA_ACTION_TYPES = { ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, - ZHA_ACTION_TYPE_CHANNEL_COMMAND: _execute_channel_command_based_action, + ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: _execute_cluster_handler_command_based_action, } diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 1a636ce65a2..d473eadeebe 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_POWER_CONFIGURATION, + CLUSTER_HANDLER_POWER_CONFIGURATION, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -44,16 +44,19 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Represent a tracked device.""" _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_name: str = "Device scanner" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA device tracker.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._battery_channel = self.cluster_channels.get(CHANNEL_POWER_CONFIGURATION) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._battery_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_POWER_CONFIGURATION + ) self._connected = False self._keepalive_interval = 60 self._battery_level = None @@ -61,9 +64,9 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() - if self._battery_channel: + if self._battery_cluster_handler: self.async_accept_signal( - self._battery_channel, + self._battery_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_battery_percentage_remaining_updated, ) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 8e0b58a8721..97258a77e2b 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -34,7 +34,7 @@ from .core.const import ( from .core.helpers import LogMixin if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,6 @@ class BaseZhaEntity(LogMixin, entity.Entity): def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: """Init ZHA entity.""" - self._name: str = "" self._unique_id: str = unique_id if self.unique_id_suffix: self._unique_id += f"-{self.unique_id_suffix}" @@ -62,13 +61,6 @@ class BaseZhaEntity(LogMixin, entity.Entity): self._unsubs: list[Callable[[], None]] = [] self.remove_future: asyncio.Future[Any] = asyncio.Future() - @property - def name(self) -> str: - """Return Entity's default name.""" - if hasattr(self, "_attr_name") and self._attr_name is not None: - return self._attr_name - return self._name - @property def unique_id(self) -> str: """Return a unique ID.""" @@ -122,19 +114,19 @@ class BaseZhaEntity(LogMixin, entity.Entity): @callback def async_accept_signal( self, - channel: ZigbeeChannel | None, + cluster_handler: ClusterHandler | None, signal: str, func: Callable[..., Any], signal_override=False, ): - """Accept a signal from a channel.""" + """Accept a signal from a cluster handler.""" unsub = None if signal_override: unsub = async_dispatcher_connect(self.hass, signal, func) else: - assert channel + assert cluster_handler unsub = async_dispatcher_connect( - self.hass, f"{channel.unique_id}_{signal}", func + self.hass, f"{cluster_handler.unique_id}_{signal}", func ) self._unsubs.append(unsub) @@ -152,7 +144,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): """Initialize subclass. :param id_suffix: suffix to add to the unique_id of the entity. Used for multi - entities using the same channel/cluster id for the entity. + entities using the same cluster handler/cluster id for the entity. """ super().__init_subclass__(**kwargs) if id_suffix: @@ -162,35 +154,29 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init ZHA entity.""" super().__init__(unique_id, zha_device, **kwargs) - self._name: str = ( - self.__class__.__name__.lower() - .replace("zha", "") - .replace("entity", "") - .replace("sensor", "") - .capitalize() - ) - self.cluster_channels: dict[str, ZigbeeChannel] = {} - for channel in channels: - self.cluster_channels[channel.name] = channel + + self.cluster_handlers: dict[str, ClusterHandler] = {} + for cluster_handler in cluster_handlers: + self.cluster_handlers[cluster_handler.name] = cluster_handler @classmethod def create_entity( cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def available(self) -> bool: @@ -220,7 +206,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): self._zha_device.ieee, self.entity_id, self._zha_device, - self.cluster_channels, + self.cluster_handlers, self.device_info, self.remove_future, ) @@ -238,9 +224,9 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): async def async_update(self) -> None: """Retrieve latest state.""" tasks = [ - channel.async_update() - for channel in self.cluster_channels.values() - if hasattr(channel, "async_update") + cluster_handler.async_update() + for cluster_handler in self.cluster_handlers.values() + if hasattr(cluster_handler, "async_update") ] if tasks: await asyncio.gather(*tasks) @@ -249,6 +235,9 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): class ZhaGroupEntity(BaseZhaEntity): """A base class for ZHA group entities.""" + # The group name is set in the initializer + _attr_name: str + def __init__( self, entity_ids: list[str], @@ -261,9 +250,6 @@ class ZhaGroupEntity(BaseZhaEntity): super().__init__(unique_id, zha_device, **kwargs) self._available = False self._group = zha_device.gateway.groups.get(group_id) - self._name = ( - f"{self._group.name}_zha_group_0x{group_id:04x}".lower().capitalize() - ) self._group_id: int = group_id self._entity_ids: list[str] = entity_ids self._async_unsub_state_changed: CALLBACK_TYPE | None = None @@ -271,6 +257,8 @@ class ZhaGroupEntity(BaseZhaEntity): self._change_listener_debouncer: Debouncer | None = None self._update_group_from_child_delay = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY + self._attr_name = self._group.name + @property def available(self) -> bool: """Return entity availability.""" @@ -320,6 +308,7 @@ class ZhaGroupEntity(BaseZhaEntity): immediate=False, function=functools.partial(self.async_update_ha_state, True), ) + self.async_on_remove(self._change_listener_debouncer.async_cancel) self._async_unsub_state_changed = async_track_state_change_event( self.hass, self._entity_ids, self.async_state_changed_listener ) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 5153d3c4567..82725accfa4 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,7 +28,12 @@ from homeassistant.util.percentage import ( ) from .core import discovery -from .core.const import CHANNEL_FAN, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.const import ( + CLUSTER_HANDLER_FAN, + DATA_ZHA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -124,50 +129,54 @@ class BaseFan(FanEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" -@STRICT_MATCH(channel_names=CHANNEL_FAN) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_FAN) class ZhaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Fan" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._fan_channel = self.cluster_channels.get(CHANNEL_FAN) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def percentage(self) -> int | None: """Return the current speed percentage.""" if ( - self._fan_channel.fan_mode is None - or self._fan_channel.fan_mode > SPEED_RANGE[1] + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > SPEED_RANGE[1] ): return None - if self._fan_channel.fan_mode == 0: + if self._fan_cluster_handler.fan_mode == 0: return 0 - return ranged_value_to_percentage(SPEED_RANGE, self._fan_channel.fan_mode) + return ranged_value_to_percentage( + SPEED_RANGE, self._fan_cluster_handler.fan_mode + ) @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + return PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the fan.""" - await self._fan_channel.async_set_speed(fan_mode) + await self._fan_cluster_handler.async_set_speed(fan_mode) self.async_set_state(0, "fan_mode", fan_mode) @@ -182,7 +191,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) self._available: bool = False group = self.zha_device.gateway.get_group(self._group_id) - self._fan_channel = group.endpoint[hvac.Fan.cluster_id] + self._fan_cluster_handler = group.endpoint[hvac.Fan.cluster_id] self._percentage = None self._preset_mode = None @@ -199,7 +208,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the group.""" try: - await self._fan_channel.write_attributes({"fan_mode": fan_mode}) + await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) except ZigbeeException as ex: self.error("Could not set fan mode: %s", ex) self.async_set_state(0, "fan_mode", fan_mode) @@ -250,22 +259,24 @@ IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) @MULTI_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class IkeaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "IKEA fan" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._fan_channel = self.cluster_channels.get("ikea_airpurifier") + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get("ikea_airpurifier") async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property @@ -296,18 +307,20 @@ class IkeaFan(BaseFan, ZhaEntity): def percentage(self) -> int | None: """Return the current speed percentage.""" if ( - self._fan_channel.fan_mode is None - or self._fan_channel.fan_mode > IKEA_SPEED_RANGE[1] + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > IKEA_SPEED_RANGE[1] ): return None - if self._fan_channel.fan_mode == 0: + if self._fan_cluster_handler.fan_mode == 0: return 0 - return ranged_value_to_percentage(IKEA_SPEED_RANGE, self._fan_channel.fan_mode) + return ranged_value_to_percentage( + IKEA_SPEED_RANGE, self._fan_cluster_handler.fan_mode + ) @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + return IKEA_PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) async def async_turn_on( self, @@ -328,10 +341,10 @@ class IkeaFan(BaseFan, ZhaEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the fan.""" - await self._fan_channel.async_set_speed(fan_mode) + await self._fan_cluster_handler.async_set_speed(fan_mode) self.async_set_state(0, "fan_mode", fan_mode) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index e7bc059054a..705176ceda4 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -39,9 +39,9 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from .core import discovery, helpers from .core.const import ( - CHANNEL_COLOR, - CHANNEL_LEVEL, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, CONF_ALWAYS_PREFER_XY_COLOR_MODE, CONF_DEFAULT_LIGHT_TRANSITION, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, @@ -113,7 +113,7 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False - _DEFAULT_MIN_TRANSITION_TIME = 0 + _DEFAULT_MIN_TRANSITION_TIME: float = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" @@ -130,14 +130,19 @@ class BaseLight(LogMixin, light.LightEntity): self._zha_config_enhanced_light_transition: bool = False self._zha_config_enable_light_transitioning_flag: bool = True self._zha_config_always_prefer_xy_color_mode: bool = True - self._on_off_channel = None - self._level_channel = None - self._color_channel = None - self._identify_channel = None + self._on_off_cluster_handler = None + self._level_cluster_handler = None + self._color_cluster_handler = None + self._identify_cluster_handler = None self._transitioning_individual: bool = False self._transitioning_group: bool = False self._transition_listener: Callable[[], None] | None = None + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + self._async_unsub_transition_listener() + await super().async_will_remove_from_hass() + @property def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" @@ -176,9 +181,7 @@ class BaseLight(LogMixin, light.LightEntity): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) duration = ( - transition * 10 - if transition is not None - else self._zha_config_transition * 10 + transition if transition is not None else self._zha_config_transition ) or ( # if 0 is passed in some devices still need the minimum default self._DEFAULT_MIN_TRANSITION_TIME @@ -193,7 +196,8 @@ class BaseLight(LogMixin, light.LightEntity): execute_if_off_supported = ( self._GROUP_SUPPORTS_EXECUTE_IF_OFF if isinstance(self, LightGroup) - else self._color_channel and self._color_channel.execute_if_off_supported + else self._color_cluster_handler + and self._color_cluster_handler.execute_if_off_supported ) set_transition_flag = ( @@ -204,7 +208,7 @@ class BaseLight(LogMixin, light.LightEntity): ) and self._zha_config_enable_light_transitioning_flag transition_time = ( ( - duration / 10 + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT + duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT if ( (brightness is not None or transition is not None) and brightness_supported(self._attr_supported_color_modes) @@ -289,9 +293,9 @@ class BaseLight(LogMixin, light.LightEntity): # If the light is currently off, we first need to turn it on at a low # brightness level with no transition. # After that, we set it to the desired color/temperature with no transition. - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=DEFAULT_MIN_BRIGHTNESS, - transition_time=self._DEFAULT_MIN_TRANSITION_TIME, + transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME), ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -329,9 +333,9 @@ class BaseLight(LogMixin, light.LightEntity): and not new_color_provided_while_off and brightness_supported(self._attr_supported_color_modes) ): - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=level, - transition_time=duration, + transition_time=int(10 * duration), ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -353,7 +357,7 @@ class BaseLight(LogMixin, light.LightEntity): # since some lights don't always turn on with move_to_level_with_on_off, # we should call the on command on the on_off cluster # if brightness is not 0. - result = await self._on_off_channel.on() + result = await self._on_off_cluster_handler.on() t_log["on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: # 'On' call failed, but as brightness may still transition @@ -383,8 +387,8 @@ class BaseLight(LogMixin, light.LightEntity): if new_color_provided_while_off: # The light is has the correct color, so we can now transition # it to the correct brightness level. - result = await self._level_channel.move_to_level( - level=level, transition_time=duration + result = await self._level_cluster_handler.move_to_level( + level=level, transition_time=int(10 * duration) ) t_log["move_to_level_if_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -400,7 +404,7 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_start_timer(transition_time) if effect == light.EFFECT_COLORLOOP: - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=( Color.ColorLoopUpdateFlags.Action | Color.ColorLoopUpdateFlags.Direction @@ -417,7 +421,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): - result = await self._color_channel.color_loop_set( + result = await self._color_cluster_handler.color_loop_set( update_flags=Color.ColorLoopUpdateFlags.Action, action=Color.ColorLoopAction.Deactivate, direction=Color.ColorLoopDirection.Decrement, @@ -428,7 +432,7 @@ class BaseLight(LogMixin, light.LightEntity): self._attr_effect = None if flash is not None: - result = await self._identify_channel.trigger_effect( + result = await self._identify_cluster_handler.trigger_effect( effect_id=FLASH_EFFECTS[flash], effect_variant=Identify.EffectVariant.Default, ) @@ -457,12 +461,14 @@ class BaseLight(LogMixin, light.LightEntity): # is not none looks odd here, but it will override built in bulb # transition times if we pass 0 in here if transition is not None and supports_level: - result = await self._level_channel.move_to_level_with_on_off( + result = await self._level_cluster_handler.move_to_level_with_on_off( level=0, - transition_time=(transition * 10 or self._DEFAULT_MIN_TRANSITION_TIME), + transition_time=int( + 10 * (transition or self._DEFAULT_MIN_TRANSITION_TIME) + ), ) else: - result = await self._on_off_channel.off() + result = await self._on_off_cluster_handler.off() # Pause parsing attribute reports until transition is complete if self._zha_config_enable_light_transitioning_flag: @@ -503,9 +509,9 @@ class BaseLight(LogMixin, light.LightEntity): ) if temperature is not None: - result = await self._color_channel.move_to_color_temp( + result = await self._color_cluster_handler.move_to_color_temp( color_temp_mireds=temperature, - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_color_temp"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -518,19 +524,19 @@ class BaseLight(LogMixin, light.LightEntity): if hs_color is not None: if ( not isinstance(self, LightGroup) - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): - result = await self._color_channel.enhanced_move_to_hue_and_saturation( + result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation( enhanced_hue=int(hs_color[0] * 65535 / 360), saturation=int(hs_color[1] * 2.54), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["enhanced_move_to_hue_and_saturation"] = result else: - result = await self._color_channel.move_to_hue_and_saturation( + result = await self._color_cluster_handler.move_to_hue_and_saturation( hue=int(hs_color[0] * 254 / 360), saturation=int(hs_color[1] * 2.54), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_hue_and_saturation"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -542,10 +548,10 @@ class BaseLight(LogMixin, light.LightEntity): xy_color = None # don't set xy_color if it is also present if xy_color is not None: - result = await self._color_channel.move_to_color( + result = await self._color_cluster_handler.move_to_color( color_x=int(xy_color[0] * 65535), color_y=int(xy_color[1] * 65535), - transition_time=transition_time, + transition_time=int(10 * transition_time), ) t_log["move_to_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -574,8 +580,7 @@ class BaseLight(LogMixin, light.LightEntity): SIGNAL_LIGHT_GROUP_TRANSITION_START, {"entity_ids": self._entity_ids}, ) - if self._transition_listener is not None: - self._transition_listener() + self._async_unsub_transition_listener() @callback def async_transition_start_timer(self, transition_time) -> None: @@ -595,14 +600,19 @@ class BaseLight(LogMixin, light.LightEntity): self.async_transition_complete, ) + @callback + def _async_unsub_transition_listener(self) -> None: + """Unsubscribe transition listener.""" + if self._transition_listener: + self._transition_listener() + self._transition_listener = None + @callback def async_transition_complete(self, _=None) -> None: """Set _transitioning_individual to False and write HA state.""" self.debug("transition complete - future attribute reports will write HA state") self._transitioning_individual = False - if self._transition_listener: - self._transition_listener() - self._transition_listener = None + self._async_unsub_transition_listener() self.async_write_ha_state() if isinstance(self, LightGroup): async_dispatcher_send( @@ -620,24 +630,30 @@ class BaseLight(LogMixin, light.LightEntity): ) -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}) +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, +) class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_name: str = "Light" _attr_supported_color_modes: set[ColorMode] _REFRESH_INTERVAL = (45, 75) - def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs) -> None: + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._attr_state = bool(self._on_off_channel.on_off) - self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) - self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) - self._identify_channel = self.zha_device.channels.identify_ch - if self._color_channel: - self._attr_min_mireds: int = self._color_channel.min_mireds - self._attr_max_mireds: int = self._color_channel.max_mireds + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._attr_state = bool(self._on_off_cluster_handler.on_off) + self._level_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL) + self._color_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COLOR) + self._identify_cluster_handler = zha_device.identify_ch + if self._color_cluster_handler: + self._attr_min_mireds: int = self._color_cluster_handler.min_mireds + self._attr_max_mireds: int = self._color_cluster_handler.max_mireds self._cancel_refresh_handle: CALLBACK_TYPE | None = None effect_list = [] @@ -649,44 +665,48 @@ class Light(BaseLight, ZhaEntity): ) self._attr_supported_color_modes = {ColorMode.ONOFF} - if self._level_channel: + if self._level_cluster_handler: self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_features |= light.LightEntityFeature.TRANSITION - self._attr_brightness = self._level_channel.current_level + self._attr_brightness = self._level_cluster_handler.current_level - if self._color_channel: - if self._color_channel.color_temp_supported: + if self._color_cluster_handler: + if self._color_cluster_handler.color_temp_supported: self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - self._attr_color_temp = self._color_channel.color_temperature + self._attr_color_temp = self._color_cluster_handler.color_temperature - if self._color_channel.xy_supported and ( + if self._color_cluster_handler.xy_supported and ( self._zha_config_always_prefer_xy_color_mode - or not self._color_channel.hs_supported + or not self._color_cluster_handler.hs_supported ): self._attr_supported_color_modes.add(ColorMode.XY) - curr_x = self._color_channel.current_x - curr_y = self._color_channel.current_y + curr_x = self._color_cluster_handler.current_x + curr_y = self._color_cluster_handler.current_y if curr_x is not None and curr_y is not None: self._attr_xy_color = (curr_x / 65535, curr_y / 65535) else: self._attr_xy_color = (0, 0) if ( - self._color_channel.hs_supported + self._color_cluster_handler.hs_supported and not self._zha_config_always_prefer_xy_color_mode ): self._attr_supported_color_modes.add(ColorMode.HS) if ( - self._color_channel.enhanced_hue_supported - and self._color_channel.enhanced_current_hue is not None + self._color_cluster_handler.enhanced_hue_supported + and self._color_cluster_handler.enhanced_current_hue is not None ): - curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360 - elif self._color_channel.current_hue is not None: - curr_hue = self._color_channel.current_hue * 254 / 360 + curr_hue = ( + self._color_cluster_handler.enhanced_current_hue * 65535 / 360 + ) + elif self._color_cluster_handler.current_hue is not None: + curr_hue = self._color_cluster_handler.current_hue * 254 / 360 else: curr_hue = 0 - if (curr_saturation := self._color_channel.current_saturation) is None: + if ( + curr_saturation := self._color_cluster_handler.current_saturation + ) is None: curr_saturation = 0 self._attr_hs_color = ( @@ -694,10 +714,10 @@ class Light(BaseLight, ZhaEntity): int(curr_saturation * 2.54), ) - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: self._attr_supported_features |= light.LightEntityFeature.EFFECT effect_list.append(light.EFFECT_COLORLOOP) - if self._color_channel.color_loop_active == 1: + if self._color_cluster_handler.color_loop_active == 1: self._attr_effect = light.EFFECT_COLORLOOP self._attr_supported_color_modes = filter_supported_color_modes( self._attr_supported_color_modes @@ -705,13 +725,16 @@ class Light(BaseLight, ZhaEntity): if len(self._attr_supported_color_modes) == 1: self._attr_color_mode = next(iter(self._attr_supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in - assert self._color_channel - if self._color_channel.color_mode == Color.ColorMode.Color_temperature: + assert self._color_cluster_handler + if ( + self._color_cluster_handler.color_mode + == Color.ColorMode.Color_temperature + ): self._attr_color_mode = ColorMode.COLOR_TEMP else: self._attr_color_mode = ColorMode.XY - if self._identify_channel: + if self._identify_cluster_handler: self._attr_supported_features |= light.LightEntityFeature.FLASH if effect_list: @@ -755,11 +778,11 @@ class Light(BaseLight, ZhaEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) - if self._level_channel: + if self._level_cluster_handler: self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.set_level + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.set_level ) refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) self._cancel_refresh_handle = async_track_time_interval( @@ -844,8 +867,8 @@ class Light(BaseLight, ZhaEntity): return self.debug("polling current state") - if self._on_off_channel: - state = await self._on_off_channel.get_attribute_value( + if self._on_off_cluster_handler: + state = await self._on_off_cluster_handler.get_attribute_value( "on_off", from_cache=False ) # check if transition started whilst waiting for polled state @@ -858,8 +881,8 @@ class Light(BaseLight, ZhaEntity): self._off_with_transition = False self._off_brightness = None - if self._level_channel: - level = await self._level_channel.get_attribute_value( + if self._level_cluster_handler: + level = await self._level_cluster_handler.get_attribute_value( "current_level", from_cache=False ) # check if transition started whilst waiting for polled state @@ -868,7 +891,7 @@ class Light(BaseLight, ZhaEntity): if level is not None: self._attr_brightness = level - if self._color_channel: + if self._color_cluster_handler: attributes = [ "color_mode", "current_x", @@ -876,23 +899,23 @@ class Light(BaseLight, ZhaEntity): ] if ( not self._zha_config_always_prefer_xy_color_mode - and self._color_channel.enhanced_hue_supported + and self._color_cluster_handler.enhanced_hue_supported ): attributes.append("enhanced_current_hue") attributes.append("current_saturation") if ( - self._color_channel.hs_supported - and not self._color_channel.enhanced_hue_supported + self._color_cluster_handler.hs_supported + and not self._color_cluster_handler.enhanced_hue_supported and not self._zha_config_always_prefer_xy_color_mode ): attributes.append("current_hue") attributes.append("current_saturation") - if self._color_channel.color_temp_supported: + if self._color_cluster_handler.color_temp_supported: attributes.append("color_temperature") - if self._color_channel.color_loop_supported: + if self._color_cluster_handler.color_loop_supported: attributes.append("color_loop_active") - results = await self._color_channel.get_attributes( + results = await self._color_cluster_handler.get_attributes( attributes, from_cache=False, only_cache=False ) @@ -915,7 +938,7 @@ class Light(BaseLight, ZhaEntity): and not self._zha_config_always_prefer_xy_color_mode ): self._attr_color_mode = ColorMode.HS - if self._color_channel.enhanced_hue_supported: + if self._color_cluster_handler.enhanced_hue_supported: current_hue = results.get("enhanced_current_hue") else: current_hue = results.get("current_hue") @@ -923,7 +946,7 @@ class Light(BaseLight, ZhaEntity): if current_hue is not None and current_saturation is not None: self._attr_hs_color = ( int(current_hue * 360 / 65535) - if self._color_channel.enhanced_hue_supported + if self._color_cluster_handler.enhanced_hue_supported else int(current_hue * 360 / 254), int(current_saturation / 2.54), ) @@ -1036,36 +1059,41 @@ class Light(BaseLight, ZhaEntity): @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Philips", "Signify Netherlands B.V."}, ) class HueLight(Light): """Representation of a HUE light which does not report attributes.""" + _attr_name: str = "Light" _REFRESH_INTERVAL = (3, 5) @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect move_to_level_with_on_off.""" + _attr_name: str = "Light" _FORCE_ON = True @STRICT_MATCH( - channel_names=CHANNEL_ON_OFF, - aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, manufacturers=DEFAULT_MIN_TRANSITION_MANUFACTURERS, ) class MinTransitionLight(Light): """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" - _DEFAULT_MIN_TRANSITION_TIME = 1 + _attr_name: str = "Light" + + # Transitions are counted in 1/10th of a second increments, so this is the smallest + _DEFAULT_MIN_TRANSITION_TIME = 0.1 @GROUP_MATCH() @@ -1085,27 +1113,31 @@ class LightGroup(BaseLight, ZhaGroupEntity): group = self.zha_device.gateway.get_group(self._group_id) self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name - # Check all group members to see if they support execute_if_off. - # If at least one member has a color cluster and doesn't support it, - # it's not used. + for member in group.members: - for pool in member.device.channels.pools: - for channel in pool.all_channels.values(): + # Ensure we do not send group commands that violate the minimum transition + # time of any members. + if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: + self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name + MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME + ) + + # Check all group members to see if they support execute_if_off. + # If at least one member has a color cluster and doesn't support it, + # it's not used. + for endpoint in member.device._endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): if ( - channel.name == CHANNEL_COLOR - and not channel.execute_if_off_supported + cluster_handler.name == CLUSTER_HANDLER_COLOR + and not cluster_handler.execute_if_off_supported ): self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False break - self._DEFAULT_MIN_TRANSITION_TIME = any( # pylint: disable=invalid-name - member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS - for member in group.members - ) - self._on_off_channel = group.endpoint[OnOff.cluster_id] - self._level_channel = group.endpoint[LevelControl.cluster_id] - self._color_channel = group.endpoint[Color.cluster_id] - self._identify_channel = group.endpoint[Identify.cluster_id] + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + self._level_cluster_handler = group.endpoint[LevelControl.cluster_id] + self._color_cluster_handler = group.endpoint[Color.cluster_id] + self._identify_cluster_handler = group.endpoint[Identify.cluster_id] self._debounced_member_refresh: Debouncer | None = None self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, @@ -1154,6 +1186,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): function=self._force_member_updates, ) self._debounced_member_refresh = force_refresh_debouncer + self.async_on_remove(force_refresh_debouncer.async_cancel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 433f662a785..2f6bce0b20e 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( - CHANNEL_DOORLOCK, + CLUSTER_HANDLER_DOORLOCK, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -92,20 +92,24 @@ async def async_setup_entry( ) -@MULTI_MATCH(channel_names=CHANNEL_DOORLOCK) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK) class ZhaDoorLock(ZhaEntity, LockEntity): """Representation of a ZHA lock.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + _attr_name: str = "Door lock" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._doorlock_channel = self.cluster_channels.get(CHANNEL_DOORLOCK) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._doorlock_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_DOORLOCK + ) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._doorlock_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback @@ -127,7 +131,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - result = await self._doorlock_channel.lock_door() + result = await self._doorlock_cluster_handler.lock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: self.error("Error with lock_door: %s", result) return @@ -135,7 +139,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - result = await self._doorlock_channel.unlock_door() + result = await self._doorlock_cluster_handler.unlock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: self.error("Error with unlock_door: %s", result) return @@ -148,14 +152,14 @@ class ZhaDoorLock(ZhaEntity, LockEntity): @callback def async_set_state(self, attr_id, attr_name, value): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self._state = VALUE_TO_STATE.get(value, self._state) self.async_write_ha_state() async def async_get_state(self, from_cache=True): """Attempt to retrieve state from the lock.""" - if self._doorlock_channel: - state = await self._doorlock_channel.get_attribute_value( + if self._doorlock_cluster_handler: + state = await self._doorlock_cluster_handler.get_attribute_value( "lock_state", from_cache=from_cache ) if state is not None: @@ -167,24 +171,26 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: """Set the user_code to index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_set_user_code(code_slot, user_code) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_set_user_code( + code_slot, user_code + ) self.debug("User code at slot %s set", code_slot) async def async_enable_lock_user_code(self, code_slot: int) -> None: """Enable user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_enable_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_enable_user_code(code_slot) self.debug("User code at slot %s enabled", code_slot) async def async_disable_lock_user_code(self, code_slot: int) -> None: """Disable user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_disable_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_disable_user_code(code_slot) self.debug("User code at slot %s disabled", code_slot) async def async_clear_lock_user_code(self, code_slot: int) -> None: """Clear the user_code at index X on the lock.""" - if self._doorlock_channel: - await self._doorlock_channel.async_clear_user_code(code_slot) + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_clear_user_code(code_slot) self.debug("User code at slot %s cleared", code_slot) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 10897c17b68..9407dc84147 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.35.1", + "bellows==0.35.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.97", - "zigpy-deconz==0.20.0", - "zigpy==0.54.1", - "zigpy-xbee==0.17.0", - "zigpy-zigate==0.10.3", - "zigpy-znp==0.10.0" + "zha-quirks==0.0.99", + "zigpy-deconz==0.21.0", + "zigpy==0.55.0", + "zigpy-xbee==0.18.0", + "zigpy-zigate==0.11.0", + "zigpy-znp==0.11.1" ], "usb": [ { diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index d35f9c3afad..6bc6f30a34f 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -18,11 +18,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_ANALOG_OUTPUT, - CHANNEL_BASIC, - CHANNEL_COLOR, - CHANNEL_INOVELLI, - CHANNEL_LEVEL, + CLUSTER_HANDLER_ANALOG_OUTPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_LEVEL, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -31,7 +31,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice _LOGGER = logging.getLogger(__name__) @@ -275,37 +275,43 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_ANALOG_OUTPUT) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" + _attr_name: str = "Number" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this entity.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._analog_output_channel = self.cluster_channels[CHANNEL_ANALOG_OUTPUT] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._analog_output_cluster_handler = self.cluster_handlers[ + CLUSTER_HANDLER_ANALOG_OUTPUT + ] async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._analog_output_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._analog_output_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_state, ) @property def native_value(self) -> float | None: """Return the current value.""" - return self._analog_output_channel.present_value + return self._analog_output_cluster_handler.present_value @property def native_min_value(self) -> float: """Return the minimum value.""" - min_present_value = self._analog_output_channel.min_present_value + min_present_value = self._analog_output_cluster_handler.min_present_value if min_present_value is not None: return min_present_value return 0 @@ -313,7 +319,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_max_value(self) -> float: """Return the maximum value.""" - max_present_value = self._analog_output_channel.max_present_value + max_present_value = self._analog_output_cluster_handler.max_present_value if max_present_value is not None: return max_present_value return 1023 @@ -321,15 +327,15 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_step(self) -> float | None: """Return the value step.""" - resolution = self._analog_output_channel.resolution + resolution = self._analog_output_cluster_handler.resolution if resolution is not None: return resolution return super().native_step @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the number entity.""" - description = self._analog_output_channel.description + description = self._analog_output_cluster_handler.description if description is not None and len(description) > 0: return f"{super().name} {description}" return super().name @@ -337,7 +343,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def icon(self) -> str | None: """Return the icon to be used for this entity.""" - application_type = self._analog_output_channel.application_type + application_type = self._analog_output_cluster_handler.application_type if application_type is not None: return ICONS.get(application_type >> 16, super().icon) return super().icon @@ -345,26 +351,26 @@ class ZhaNumber(ZhaEntity, NumberEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - engineering_units = self._analog_output_channel.engineering_units + engineering_units = self._analog_output_cluster_handler.engineering_units return UNITS.get(engineering_units) @callback def async_set_state(self, attr_id, attr_name, value): - """Handle value update from channel.""" + """Handle value update from cluster handler.""" self.async_write_ha_state() async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" num_value = float(value) - if await self._analog_output_channel.async_set_present_value(num_value): + if await self._analog_output_cluster_handler.async_set_present_value(num_value): self.async_write_ha_state() async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" await super().async_update() _LOGGER.debug("polling current state") - if self._analog_output_channel: - value = await self._analog_output_channel.get_attribute_value( + if self._analog_output_cluster_handler: + value = await self._analog_output_cluster_handler.get_attribute_value( "present_value", from_cache=False ) _LOGGER.debug("read value=%s", value) @@ -383,17 +389,17 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._zcl_attribute) is None + cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -402,28 +408,31 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this number configuration entity.""" - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) * self._attr_multiplier + return ( + self._cluster_handler.cluster.get(self._zcl_attribute) + * self._attr_multiplier + ) async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: - res = await self._channel.cluster.write_attributes( + res = await self._cluster_handler.cluster.write_attributes( {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: @@ -438,15 +447,16 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Attempt to retrieve the state of the entity.""" await super().async_update() _LOGGER.debug("polling current state") - if self._channel: - value = await self._channel.get_attribute_value( + if self._cluster_handler: + value = await self._cluster_handler.get_attribute_value( self._zcl_attribute, from_cache=False ) _LOGGER.debug("read value=%s", value) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.motion.ac02", "lumi.motion.agl04"} + cluster_handler_names="opple_cluster", + models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) class AqaraMotionDetectionInterval( ZHANumberConfigurationEntity, id_suffix="detection_interval" @@ -459,7 +469,7 @@ class AqaraMotionDetectionInterval( _attr_name = "Detection interval" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnOffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" ): @@ -471,7 +481,7 @@ class OnOffTransitionTimeConfigurationEntity( _attr_name = "On/Off transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" @@ -481,7 +491,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev _attr_name = "On level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OnTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_transition_time" ): @@ -493,7 +503,7 @@ class OnTransitionTimeConfigurationEntity( _attr_name = "On transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class OffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="off_transition_time" ): @@ -505,7 +515,7 @@ class OffTransitionTimeConfigurationEntity( _attr_name = "Off transition time" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class DefaultMoveRateConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="default_move_rate" ): @@ -517,7 +527,7 @@ class DefaultMoveRateConfigurationEntity( _attr_name = "Default move rate" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) class StartUpCurrentLevelConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_current_level" ): @@ -529,7 +539,7 @@ class StartUpCurrentLevelConfigurationEntity( _attr_name = "Start-up current level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_COLOR) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) class StartUpColorTemperatureConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" ): @@ -544,18 +554,18 @@ class StartUpColorTemperatureConfigurationEntity( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this ZHA startup color temperature entity.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - if self._channel: - self._attr_native_min_value: float = self._channel.min_mireds - self._attr_native_max_value: float = self._channel.max_mireds + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if self._cluster_handler: + self._attr_native_min_value: float = self._cluster_handler.min_mireds + self._attr_native_max_value: float = self._cluster_handler.max_mireds @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -572,7 +582,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_name = "Timer duration" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="ikea_airpurifier") +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): """Representation of a ZHA filter lifetime configuration entity.""" @@ -586,7 +596,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_BASIC, + cluster_handler_names=CLUSTER_HANDLER_BASIC, manufacturers={"TexasInstruments"}, models={"ti.router"}, ) @@ -599,7 +609,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po _attr_name = "Transmit power" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" ): @@ -613,7 +623,7 @@ class InovelliRemoteDimmingUpSpeed( _attr_name: str = "Remote dimming up speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): """Inovelli button delay configuration entity.""" @@ -625,7 +635,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay" _attr_name: str = "Button delay" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" ): @@ -639,7 +649,7 @@ class InovelliLocalDimmingUpSpeed( _attr_name: str = "Local dimming up speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalRampRateOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" ): @@ -653,7 +663,7 @@ class InovelliLocalRampRateOffToOn( _attr_name: str = "Local ramp rate off to on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingSpeedOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" ): @@ -667,7 +677,7 @@ class InovelliRemoteDimmingSpeedOffToOn( _attr_name: str = "Remote ramp rate off to on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" ): @@ -681,7 +691,7 @@ class InovelliRemoteDimmingDownSpeed( _attr_name: str = "Remote dimming down speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" ): @@ -695,7 +705,7 @@ class InovelliLocalDimmingDownSpeed( _attr_name: str = "Local dimming down speed" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLocalRampRateOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" ): @@ -709,7 +719,7 @@ class InovelliLocalRampRateOnToOff( _attr_name: str = "Local ramp rate on to off" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliRemoteDimmingSpeedOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" ): @@ -723,7 +733,7 @@ class InovelliRemoteDimmingSpeedOnToOff( _attr_name: str = "Remote ramp rate on to off" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliMinimumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="minimum_level" ): @@ -737,7 +747,7 @@ class InovelliMinimumLoadDimmingLevel( _attr_name: str = "Minimum load dimming level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliMaximumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="maximum_level" ): @@ -751,7 +761,7 @@ class InovelliMaximumLoadDimmingLevel( _attr_name: str = "Maximum load dimming level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliAutoShutoffTimer( ZHANumberConfigurationEntity, id_suffix="auto_off_timer" ): @@ -765,7 +775,7 @@ class InovelliAutoShutoffTimer( _attr_name: str = "Automatic switch shutoff timer" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliLoadLevelIndicatorTimeout( ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" ): @@ -779,7 +789,7 @@ class InovelliLoadLevelIndicatorTimeout( _attr_name: str = "Load level indicator timeout" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOnColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_on" ): @@ -793,7 +803,7 @@ class InovelliDefaultAllLEDOnColor( _attr_name: str = "Default all LED on color" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOffColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_off" ): @@ -807,7 +817,7 @@ class InovelliDefaultAllLEDOffColor( _attr_name: str = "Default all LED off color" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOnIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" ): @@ -821,7 +831,7 @@ class InovelliDefaultAllLEDOnIntensity( _attr_name: str = "Default all LED on intensity" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDefaultAllLEDOffIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" ): @@ -835,7 +845,7 @@ class InovelliDefaultAllLEDOffIntensity( _attr_name: str = "Default all LED off intensity" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDoubleTapUpLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" ): @@ -849,7 +859,7 @@ class InovelliDoubleTapUpLevel( _attr_name: str = "Double tap up level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) class InovelliDoubleTapDownLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" ): @@ -863,7 +873,9 @@ class InovelliDoubleTapDownLevel( _attr_name: str = "Double tap down level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): """Aqara pet feeder serving size configuration entity.""" @@ -876,7 +888,9 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving _attr_icon: str = "mdi:counter" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederPortionWeight( ZHANumberConfigurationEntity, id_suffix="portion_weight" ): @@ -892,7 +906,9 @@ class AqaraPetFeederPortionWeight( _attr_icon: str = "mdi:weight-gram" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatAwayTemp( ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" ): diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 1f02c94e61f..9fbfa03b928 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -40,6 +40,12 @@ AUTOPROBE_RADIOS = ( RadioType.zigate, ) +RECOMMENDED_RADIOS = ( + RadioType.ezsp, + RadioType.znp, + RadioType.deconz, +) + CONNECT_DELAY_S = 1.0 MIGRATION_RETRIES = 100 diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b352176411a..2453f40af44 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -20,10 +20,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_IAS_WD, - CHANNEL_INOVELLI, - CHANNEL_OCCUPANCY, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_HUE_OCCUPANCY, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -33,7 +33,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice @@ -74,33 +74,35 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this select entity.""" self._attribute = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._channel.data_cache.get(self._attribute) + option = self._cluster_handler.data_cache.get(self._attribute) if option is None: return None return option.name.replace("_", " ") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._channel.data_cache[self._attribute] = self._enum[option.replace(" ", "_")] + self._cluster_handler.data_cache[self._attribute] = self._enum[ + option.replace(" ", "_") + ] self.async_write_ha_state() @callback def async_restore_last_state(self, last_state) -> None: """Restore previous state.""" if last_state.state and last_state.state != STATE_UNKNOWN: - self._channel.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute] = self._enum[ last_state.state.replace(" ", "_") ] @@ -114,7 +116,7 @@ class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): return True -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultToneSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__ ): @@ -124,7 +126,7 @@ class ZHADefaultToneSelectEntity( _attr_name = "Default siren tone" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultSirenLevelSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__ ): @@ -134,7 +136,7 @@ class ZHADefaultSirenLevelSelectEntity( _attr_name = "Default siren level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultStrobeLevelSelectEntity( ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__ ): @@ -144,7 +146,7 @@ class ZHADefaultStrobeLevelSelectEntity( _attr_name = "Default strobe level" -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): """Representation of a ZHA default siren strobe select entity.""" @@ -164,17 +166,17 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._select_attr in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._select_attr) is None + cls._select_attr in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._select_attr) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -183,24 +185,24 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this select entity.""" self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._channel.cluster.get(self._select_attr) + option = self._cluster_handler.cluster.get(self._select_attr) if option is None: return None option = self._enum(option) @@ -208,7 +210,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._channel.cluster.write_attributes( + await self._cluster_handler.cluster.write_attributes( {self._select_attr: self._enum[option.replace(" ", "_")]} ) self.async_write_ha_state() @@ -217,16 +219,16 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() -@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class ZHAStartupOnOffSelectEntity( ZCLEnumSelectEntity, id_suffix=OnOff.StartUpOnOff.__name__ ): @@ -246,11 +248,11 @@ class TuyaPowerOnState(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_7tdtqgwv", "_TZE200_amp6tsvy", @@ -287,7 +289,7 @@ class TuyaBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): @@ -308,7 +310,7 @@ class MoesBacklightMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_7tdtqgwv", "_TZE200_amp6tsvy", @@ -345,7 +347,7 @@ class AqaraMotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02", "lumi.motion.agl04"}, ) class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): @@ -365,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) @@ -388,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) @@ -407,7 +409,9 @@ class AqaraMonitoringModess(types.enum8): Left_Right = 0x01 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): """Representation of a ZHA monitoring mode configuration entity.""" @@ -424,7 +428,9 @@ class AqaraApproachDistances(types.enum8): Near = 0x02 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): """Representation of a ZHA approach distance configuration entity.""" @@ -441,7 +447,7 @@ class AqaraE1ReverseDirection(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="window_covering", models={"lumi.curtain.agl001"} + cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} ) class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): """Representation of a ZHA curtain mode configuration entity.""" @@ -459,7 +465,7 @@ class InovelliOutputMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): """Inovelli output mode control.""" @@ -479,7 +485,7 @@ class InovelliSwitchType(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): """Inovelli switch type control.""" @@ -497,7 +503,7 @@ class InovelliLedScalingMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliLedScalingModeEntity(ZCLEnumSelectEntity, id_suffix="led_scaling_mode"): """Inovelli led mode control.""" @@ -515,7 +521,7 @@ class InovelliNonNeutralOutput(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliNonNeutralOutputEntity( ZCLEnumSelectEntity, id_suffix="increased_non_neutral_output" @@ -534,7 +540,9 @@ class AqaraFeedingMode(types.enum8): Schedule = 0x01 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): """Representation of an Aqara pet feeder mode configuration entity.""" @@ -552,7 +560,9 @@ class AqaraThermostatPresetMode(types.enum8): Away = 0x02 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): """Representation of an Aqara thermostat preset configuration entity.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index a7a090b13af..52c1f6a5b19 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,19 +46,19 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( - CHANNEL_ANALOG_INPUT, - CHANNEL_BASIC, - CHANNEL_DEVICE_TEMPERATURE, - CHANNEL_ELECTRICAL_MEASUREMENT, - CHANNEL_HUMIDITY, - CHANNEL_ILLUMINANCE, - CHANNEL_LEAF_WETNESS, - CHANNEL_POWER_CONFIGURATION, - CHANNEL_PRESSURE, - CHANNEL_SMARTENERGY_METERING, - CHANNEL_SOIL_MOISTURE, - CHANNEL_TEMPERATURE, - CHANNEL_THERMOSTAT, + CLUSTER_HANDLER_ANALOG_INPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_DEVICE_TEMPERATURE, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_HUMIDITY, + CLUSTER_HANDLER_ILLUMINANCE, + CLUSTER_HANDLER_LEAF_WETNESS, + CLUSTER_HANDLER_POWER_CONFIGURATION, + CLUSTER_HANDLER_PRESSURE, + CLUSTER_HANDLER_SMARTENERGY_METERING, + CLUSTER_HANDLER_SOIL_MOISTURE, + CLUSTER_HANDLER_TEMPERATURE, + CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -67,7 +67,7 @@ from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice PARALLEL_UPDATES = 5 @@ -88,7 +88,9 @@ BATTERY_SIZES = { 255: "Unknown", } -CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( + f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) @@ -125,50 +127,50 @@ class Sensor(ZhaEntity, SensorEntity): self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this sensor.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ZigbeeChannel = channels[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] @classmethod def create_entity( cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] - if cls.SENSOR_ATTR in channel.cluster.unsupported_attributes: + cluster_handler = cluster_handlers[0] + if cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes: return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @property def native_value(self) -> StateType: """Return the state of the entity.""" assert self.SENSOR_ATTR is not None - raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) if raw_state is None: return None return self.formatter(raw_state) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: @@ -181,17 +183,18 @@ class Sensor(ZhaEntity, SensorEntity): @MULTI_MATCH( - channel_names=CHANNEL_ANALOG_INPUT, + cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", - stop_on_match_group=CHANNEL_ANALOG_INPUT, + stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) class AnalogInput(Sensor): """Sensor that displays analog input values.""" SENSOR_ATTR = "present_value" + _attr_name: str = "Analog input" -@MULTI_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -207,7 +210,7 @@ class Battery(Sensor): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -216,7 +219,9 @@ class Battery(Sensor): battery_percent_remaining attribute, but zha-device-handlers takes care of it so create the entity regardless """ - return cls(unique_id, zha_device, channels, **kwargs) + if zha_device.is_mains_powered: + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @staticmethod def formatter(value: int) -> int | None: # pylint: disable=arguments-differ @@ -231,25 +236,28 @@ class Battery(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" state_attrs = {} - battery_size = self._channel.cluster.get("battery_size") + battery_size = self._cluster_handler.cluster.get("battery_size") if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = self._channel.cluster.get("battery_quantity") + battery_quantity = self._cluster_handler.cluster.get("battery_quantity") if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity - battery_voltage = self._channel.cluster.get("battery_voltage") + battery_voltage = self._cluster_handler.cluster.get("battery_voltage") if battery_voltage is not None: state_attrs["battery_voltage"] = round(battery_voltage / 10, 2) return state_attrs -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + models={"VZM31-SN", "SP 234", "outletv4"}, +) class ElectricalMeasurement(Sensor): """Active power measurement.""" SENSOR_ATTR = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER - _attr_should_poll = True # BaseZhaEntity defaults to False _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Active power" _attr_native_unit_of_measurement: str = UnitOfPower.WATT @@ -259,24 +267,36 @@ class ElectricalMeasurement(Sensor): def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for sensor.""" attrs = {} - if self._channel.measurement_type is not None: - attrs["measurement_type"] = self._channel.measurement_type + if self._cluster_handler.measurement_type is not None: + attrs["measurement_type"] = self._cluster_handler.measurement_type max_attr_name = f"{self.SENSOR_ATTR}_max" - if (max_v := self._channel.cluster.get(max_attr_name)) is not None: + if (max_v := self._cluster_handler.cluster.get(max_attr_name)) is not None: attrs[max_attr_name] = str(self.formatter(max_v)) return attrs def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" - multiplier = getattr(self._channel, f"{self._div_mul_prefix}_multiplier") - divisor = getattr(self._channel, f"{self._div_mul_prefix}_divisor") + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") value = float(value * multiplier) / divisor if value < 100 and divisor > 1: return round(value, self._decimals) return round(value) + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, +) +class PolledElectricalMeasurement(ElectricalMeasurement): + """Polled active power measurement.""" + + _attr_should_poll = True # BaseZhaEntity defaults to False + async def async_update(self) -> None: """Retrieve latest state.""" if not self.available: @@ -284,7 +304,7 @@ class ElectricalMeasurement(Sensor): await super().async_update() -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementApparentPower( ElectricalMeasurement, id_suffix="apparent_power" ): @@ -292,63 +312,62 @@ class ElectricalMeasurementApparentPower( SENSOR_ATTR = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" SENSOR_ATTR = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): """RMS Voltage measurement.""" SENSOR_ATTR = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): """Frequency measurement.""" SENSOR_ATTR = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "AC frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ _div_mul_prefix = "ac_frequency" -@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): """Frequency measurement.""" SENSOR_ATTR = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE @MULTI_MATCH( - generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER, stop_on_match_group=CHANNEL_HUMIDITY + generic_ids=CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, +) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) -@MULTI_MATCH(channel_names=CHANNEL_HUMIDITY, stop_on_match_group=CHANNEL_HUMIDITY) class Humidity(Sensor): """Humidity sensor.""" @@ -360,7 +379,7 @@ class Humidity(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -372,7 +391,7 @@ class SoilMoisture(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_LEAF_WETNESS) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -384,7 +403,7 @@ class LeafWetness(Sensor): _attr_native_unit_of_measurement = PERCENTAGE -@MULTI_MATCH(channel_names=CHANNEL_ILLUMINANCE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) class Illuminance(Sensor): """Illuminance Sensor.""" @@ -400,8 +419,8 @@ class Illuminance(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -428,21 +447,21 @@ class SmartEnergyMetering(Sensor): } def formatter(self, value: int) -> int | float: - """Pass through channel formatter.""" - return self._channel.demand_formatter(value) + """Pass through cluster handler formatter.""" + return self._cluster_handler.demand_formatter(value) @property def native_unit_of_measurement(self) -> str | None: """Return Unit of measurement.""" - return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + return self.unit_of_measure_map.get(self._cluster_handler.unit_of_measurement) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" attrs = {} - if self._channel.device_type is not None: - attrs["device_type"] = self._channel.device_type - if (status := self._channel.status) is not None: + if self._cluster_handler.device_type is not None: + attrs["device_type"] = self._cluster_handler.device_type + if (status := self._cluster_handler.status) is not None: if isinstance(status, enum.IntFlag) and sys.version_info >= (3, 11): attrs["status"] = str( status.name if status.name is not None else status.value @@ -453,8 +472,8 @@ class SmartEnergyMetering(Sensor): @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -482,17 +501,20 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") def formatter(self, value: int) -> int | float: """Numeric pass-through formatter.""" - if self._channel.unit_of_measurement != 0: - return self._channel.summa_formatter(value) + if self._cluster_handler.unit_of_measurement != 0: + return self._cluster_handler.summa_formatter(value) - cooked = float(self._channel.multiplier * value) / self._channel.divisor + cooked = ( + float(self._cluster_handler.multiplier * value) + / self._cluster_handler.divisor + ) return round(cooked, 3) @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"TS011F", "ZLinky_TIC"}, - stop_on_match_group=CHANNEL_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -503,11 +525,11 @@ class PolledSmartEnergySummation(SmartEnergySummation): """Retrieve latest state.""" if not self.available: return - await self._channel.async_force_update() + await self._cluster_handler.async_force_update() @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier1SmartEnergySummation( @@ -520,7 +542,7 @@ class Tier1SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier2SmartEnergySummation( @@ -533,7 +555,7 @@ class Tier2SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier3SmartEnergySummation( @@ -546,7 +568,7 @@ class Tier3SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier4SmartEnergySummation( @@ -559,7 +581,7 @@ class Tier4SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier5SmartEnergySummation( @@ -572,7 +594,7 @@ class Tier5SmartEnergySummation( @MULTI_MATCH( - channel_names=CHANNEL_SMARTENERGY_METERING, + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) class Tier6SmartEnergySummation( @@ -584,7 +606,7 @@ class Tier6SmartEnergySummation( _attr_name: str = "Tier 6 summation delivered" -@MULTI_MATCH(channel_names=CHANNEL_PRESSURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) class Pressure(Sensor): """Pressure sensor.""" @@ -596,7 +618,7 @@ class Pressure(Sensor): _attr_native_unit_of_measurement = UnitOfPressure.HPA -@MULTI_MATCH(channel_names=CHANNEL_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) class Temperature(Sensor): """Temperature Sensor.""" @@ -608,7 +630,7 @@ class Temperature(Sensor): _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS -@MULTI_MATCH(channel_names=CHANNEL_DEVICE_TEMPERATURE) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -621,7 +643,7 @@ class DeviceTemperature(Sensor): _attr_entity_category = EntityCategory.DIAGNOSTIC -@MULTI_MATCH(channel_names="carbon_dioxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -634,7 +656,7 @@ class CarbonDioxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names="carbon_monoxide_concentration") +@MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -647,8 +669,8 @@ class CarbonMonoxideConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(generic_ids="channel_0x042e", stop_on_match_group="voc_level") -@MULTI_MATCH(channel_names="voc_level", stop_on_match_group="voc_level") +@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") class VOCLevel(Sensor): """VOC Level sensor.""" @@ -662,7 +684,7 @@ class VOCLevel(Sensor): @MULTI_MATCH( - channel_names="voc_level", + cluster_handler_names="voc_level", models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) @@ -678,7 +700,7 @@ class PPBVOCLevel(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION -@MULTI_MATCH(channel_names="pm25") +@MULTI_MATCH(cluster_handler_names="pm25") class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" @@ -690,7 +712,7 @@ class PM25(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -@MULTI_MATCH(channel_names="formaldehyde_concentration") +@MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -702,7 +724,10 @@ class FormaldehydeConcentration(Sensor): _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT, stop_on_match_group=CHANNEL_THERMOSTAT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -713,7 +738,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -721,14 +746,14 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> str | None: """Return the current HVAC action.""" if ( - self._channel.pi_heating_demand is None - and self._channel.pi_cooling_demand is None + self._cluster_handler.pi_heating_demand is None + and self._cluster_handler.pi_cooling_demand is None ): return self._rm_rs_action return self._pi_demand_action @@ -737,36 +762,36 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _rm_rs_action(self) -> HVACAction | None: """Return the current HVAC action based on running mode and running state.""" - if (running_state := self._channel.running_state) is None: + if (running_state := self._cluster_handler.running_state) is None: return None rs_heat = ( - self._channel.RunningState.Heat_State_On - | self._channel.RunningState.Heat_2nd_Stage_On + self._cluster_handler.RunningState.Heat_State_On + | self._cluster_handler.RunningState.Heat_2nd_Stage_On ) if running_state & rs_heat: return HVACAction.HEATING rs_cool = ( - self._channel.RunningState.Cool_State_On - | self._channel.RunningState.Cool_2nd_Stage_On + self._cluster_handler.RunningState.Cool_State_On + | self._cluster_handler.RunningState.Cool_2nd_Stage_On ) if running_state & rs_cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN - running_state = self._channel.running_state - if running_state and running_state & self._channel.RunningState.Idle: + running_state = self._cluster_handler.running_state + if running_state and running_state & self._cluster_handler.RunningState.Idle: return HVACAction.IDLE - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @@ -774,27 +799,27 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def _pi_demand_action(self) -> HVACAction: """Return the current HVAC action based on pi_demands.""" - heating_demand = self._channel.pi_heating_demand + heating_demand = self._cluster_handler.pi_heating_demand if heating_demand is not None and heating_demand > 0: return HVACAction.HEATING - cooling_demand = self._channel.pi_cooling_demand + cooling_demand = self._cluster_handler.pi_cooling_demand if cooling_demand is not None and cooling_demand > 0: return HVACAction.COOLING - if self._channel.system_mode != self._channel.SystemMode.Off: + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: return HVACAction.IDLE return HVACAction.OFF @callback def async_set_state(self, *args, **kwargs) -> None: - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() @MULTI_MATCH( - channel_names={CHANNEL_THERMOSTAT}, + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, manufacturers="Sinope Technologies", - stop_on_match_group=CHANNEL_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -803,28 +828,28 @@ class SinopeHVACAction(ThermostatHVACAction): def _rm_rs_action(self) -> HVACAction: """Return the current HVAC action based on running mode and running state.""" - running_mode = self._channel.running_mode - if running_mode == self._channel.RunningMode.Heat: + running_mode = self._cluster_handler.running_mode + if running_mode == self._cluster_handler.RunningMode.Heat: return HVACAction.HEATING - if running_mode == self._channel.RunningMode.Cool: + if running_mode == self._cluster_handler.RunningMode.Cool: return HVACAction.COOLING - running_state = self._channel.running_state + running_state = self._cluster_handler.running_state if running_state and running_state & ( - self._channel.RunningState.Fan_State_On - | self._channel.RunningState.Fan_2nd_Stage_On - | self._channel.RunningState.Fan_3rd_Stage_On + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On ): return HVACAction.FAN if ( - self._channel.system_mode != self._channel.SystemMode.Off - and running_mode == self._channel.SystemMode.Off + self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off + and running_mode == self._cluster_handler.SystemMode.Off ): return HVACAction.IDLE return HVACAction.OFF -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class RSSISensor(Sensor, id_suffix="rssi"): """RSSI sensor for a device.""" @@ -842,17 +867,17 @@ class RSSISensor(Sensor, id_suffix="rssi"): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - key = f"{CHANNEL_BASIC}_{cls.unique_id_suffix}" + key = f"{CLUSTER_HANDLER_BASIC}_{cls.unique_id_suffix}" if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) @property def native_value(self) -> StateType: @@ -860,7 +885,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): return getattr(self._zha_device.device, self.unique_id_suffix) -@MULTI_MATCH(channel_names=CHANNEL_BASIC) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" @@ -870,7 +895,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"): @MULTI_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_htnnfasr", }, @@ -885,7 +910,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -896,7 +921,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _attr_native_unit_of_measurement = UnitOfTime.MINUTES -@MULTI_MATCH(channel_names="ikea_airpurifier") +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" @@ -914,7 +939,7 @@ class AqaraFeedingSource(types.enum8): HomeAssistant = 0x02 -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): """Sensor that displays the last feeding source of pet feeder.""" @@ -927,7 +952,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): return AqaraFeedingSource(value).name -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): """Sensor that displays the last feeding size of the pet feeder.""" @@ -936,7 +961,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -946,7 +971,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): _attr_icon: str = "mdi:counter" -@MULTI_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -957,7 +982,7 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): _attr_icon: str = "mdi:weight-gram" -@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) 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/siren.py b/homeassistant/components/zha/siren.py index dedb339292e..a4c699d515b 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -22,9 +22,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .core import discovery -from .core.channels.security import IasWd +from .core.cluster_handlers.security import IasWd from .core.const import ( - CHANNEL_IAS_WD, + CLUSTER_HANDLER_IAS_WD, DATA_ZHA, SIGNAL_ADD_ENTITIES, WARNING_DEVICE_MODE_BURGLAR, @@ -43,7 +43,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) @@ -70,15 +70,17 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) class ZHASiren(ZhaEntity, SirenEntity): """Representation of a ZHA siren.""" + _attr_name: str = "Siren" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs, ) -> None: """Init this siren.""" @@ -97,8 +99,8 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } - super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: IasWd = cast(IasWd, channels[0]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: IasWd = cast(IasWd, cluster_handlers[0]) self._attr_is_on: bool = False self._off_listener: Callable[[], None] | None = None @@ -107,22 +109,28 @@ class ZHASiren(ZhaEntity, SirenEntity): if self._off_listener: self._off_listener() self._off_listener = None - tone_cache = self._channel.data_cache.get(WD.Warning.WarningMode.__name__) + tone_cache = self._cluster_handler.data_cache.get( + WD.Warning.WarningMode.__name__ + ) siren_tone = ( tone_cache.value if tone_cache is not None else WARNING_DEVICE_MODE_EMERGENCY ) siren_duration = DEFAULT_DURATION - level_cache = self._channel.data_cache.get(WD.Warning.SirenLevel.__name__) + level_cache = self._cluster_handler.data_cache.get( + WD.Warning.SirenLevel.__name__ + ) siren_level = ( level_cache.value if level_cache is not None else WARNING_DEVICE_SOUND_HIGH ) - strobe_cache = self._channel.data_cache.get(Strobe.__name__) + strobe_cache = self._cluster_handler.data_cache.get(Strobe.__name__) should_strobe = ( strobe_cache.value if strobe_cache is not None else Strobe.No_Strobe ) - strobe_level_cache = self._channel.data_cache.get(WD.StrobeLevel.__name__) + strobe_level_cache = self._cluster_handler.data_cache.get( + WD.StrobeLevel.__name__ + ) strobe_level = ( strobe_level_cache.value if strobe_level_cache is not None @@ -134,7 +142,7 @@ class ZHASiren(ZhaEntity, SirenEntity): siren_tone = tone if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: siren_level = int(level) - await self._channel.issue_start_warning( + await self._cluster_handler.issue_start_warning( mode=siren_tone, warning_duration=siren_duration, siren_level=siren_level, @@ -150,7 +158,7 @@ class ZHASiren(ZhaEntity, SirenEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" - await self._channel.issue_start_warning( + await self._cluster_handler.issue_start_warning( mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO ) self._attr_is_on = False diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 132f6ed9d95..94b3951f014 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -27,6 +27,10 @@ "flow_control": "data flow control" } }, + "verify_radio": { + "title": "Radio is not recommended", + "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." + }, "choose_formation_strategy": { "title": "Network Formation", "description": "Choose the network settings for your radio.", @@ -116,6 +120,10 @@ "flow_control": "[%key:component::zha::config::step::manual_port_config::data::flow_control%]" } }, + "verify_radio": { + "title": "[%key:component::zha::config::step::verify_radio::title%]", + "description": "[%key:component::zha::config::step::verify_radio::description%]" + }, "choose_formation_strategy": { "title": "[%key:component::zha::config::step::choose_formation_strategy::title%]", "description": "[%key:component::zha::config::step::choose_formation_strategy::description%]", diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index c57075a15ca..99db68760a8 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -19,9 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( - CHANNEL_BASIC, - CHANNEL_INOVELLI, - CHANNEL_ON_OFF, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -30,7 +30,7 @@ from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity if TYPE_CHECKING: - from .core.channels.base import ZigbeeChannel + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) @@ -60,59 +60,63 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" + _attr_name: str = "Switch" + def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Initialize the ZHA switch.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - if self._on_off_channel.on_off is None: + if self._on_off_cluster_handler.on_off is None: return False - return self._on_off_channel.on_off + return self._on_off_cluster_handler.on_off async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - result = await self._on_off_channel.turn_on() + result = await self._on_off_cluster_handler.turn_on() if not result: return self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self._on_off_channel.turn_off() + result = await self._on_off_cluster_handler.turn_off() if not result: return self.async_write_ha_state() @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" await super().async_update() - if self._on_off_channel: - await self._on_off_channel.get_attribute_value("on_off", from_cache=False) + if self._on_off_cluster_handler: + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) @GROUP_MATCH() @@ -132,7 +136,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): self._available: bool self._state: bool group = self.zha_device.gateway.get_group(self._group_id) - self._on_off_channel = group.endpoint[OnOff.cluster_id] + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] @property def is_on(self) -> bool: @@ -141,7 +145,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - result = await self._on_off_channel.on() + result = await self._on_off_cluster_handler.on() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = True @@ -149,7 +153,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self._on_off_channel.off() + result = await self._on_off_cluster_handler.off() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = False @@ -178,17 +182,17 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): cls, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - channel = channels[0] + cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in channel.cluster.unsupported_attributes - or channel.cluster.get(cls._zcl_attribute) is None + cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes + or cluster_handler.cluster.get(cls._zcl_attribute) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -197,48 +201,48 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ) return None - return cls(unique_id, zha_device, channels, **kwargs) + return cls(unique_id, zha_device, cluster_handlers, **kwargs) def __init__( self, unique_id: str, zha_device: ZHADevice, - channels: list[ZigbeeChannel], + cluster_handlers: list[ClusterHandler], **kwargs: Any, ) -> None: """Init this number configuration entity.""" - self._channel: ZigbeeChannel = channels[0] - super().__init__(unique_id, zha_device, channels, **kwargs) + self._cluster_handler: ClusterHandler = cluster_handlers[0] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state ) @callback def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from channel.""" + """Handle state update from cluster handler.""" self.async_write_ha_state() @property def inverted(self) -> bool: """Return True if the switch is inverted.""" if self._zcl_inverter_attribute: - return bool(self._channel.cluster.get(self._zcl_inverter_attribute)) + return bool(self._cluster_handler.cluster.get(self._zcl_inverter_attribute)) return self._force_inverted @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._channel.cluster.get(self._zcl_attribute)) + val = bool(self._cluster_handler.cluster.get(self._zcl_attribute)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" try: - result = await self._channel.cluster.write_attributes( + result = await self._cluster_handler.cluster.write_attributes( {self._zcl_attribute: not state if self.inverted else state} ) except zigpy.exceptions.ZigbeeException as ex: @@ -261,18 +265,18 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """Attempt to retrieve the state of the entity.""" await super().async_update() self.error("Polling current state") - if self._channel: - value = await self._channel.get_attribute_value( + if self._cluster_handler: + value = await self._cluster_handler.get_attribute_value( self._zcl_attribute, from_cache=False ) - await self._channel.get_attribute_value( + await self._cluster_handler.get_attribute_value( self._zcl_inverter_attribute, from_cache=False ) self.debug("read value=%s, inverted=%s", value, self.inverted) @CONFIG_DIAGNOSTIC_MATCH( - channel_names="tuya_manufacturer", + cluster_handler_names="tuya_manufacturer", manufacturers={ "_TZE200_b6wax7g0", }, @@ -284,9 +288,12 @@ class OnOffWindowDetectionFunctionConfigurationEntity( _zcl_attribute: str = "window_detection_function" _zcl_inverter_attribute: str = "window_detection_function_inverter" + _attr_name: str = "Invert window detection" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} +) class P1MotionTriggerIndicatorSwitch( ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" ): @@ -297,7 +304,8 @@ class P1MotionTriggerIndicatorSwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) class XiaomiPlugPowerOutageMemorySwitch( ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" @@ -309,7 +317,7 @@ class XiaomiPlugPowerOutageMemorySwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_BASIC, + cluster_handler_names=CLUSTER_HANDLER_BASIC, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001", "SML002", "SML003", "SML004"}, ) @@ -323,7 +331,7 @@ class HueMotionTriggerIndicatorSwitch( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @@ -334,7 +342,7 @@ class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @CONFIG_DIAGNOSTIC_MATCH( - channel_names="ikea_airpurifier", + cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): @@ -345,7 +353,7 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switch"): """Inovelli invert switch control.""" @@ -355,7 +363,7 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switc @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_mode"): """Inovelli smart bulb mode control.""" @@ -365,7 +373,7 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_ @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliDoubleTapUpEnabled( ZHASwitchConfigurationEntity, id_suffix="double_tap_up_enabled" @@ -377,7 +385,7 @@ class InovelliDoubleTapUpEnabled( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliDoubleTapDownEnabled( ZHASwitchConfigurationEntity, id_suffix="double_tap_down_enabled" @@ -389,7 +397,7 @@ class InovelliDoubleTapDownEnabled( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliAuxSwitchScenes( ZHASwitchConfigurationEntity, id_suffix="aux_switch_scenes" @@ -401,7 +409,7 @@ class InovelliAuxSwitchScenes( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliBindingOffToOnSyncLevel( ZHASwitchConfigurationEntity, id_suffix="binding_off_to_on_sync_level" @@ -413,7 +421,7 @@ class InovelliBindingOffToOnSyncLevel( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliLocalProtection( ZHASwitchConfigurationEntity, id_suffix="local_protection" @@ -425,7 +433,7 @@ class InovelliLocalProtection( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_mode"): """Inovelli only 1 LED mode control.""" @@ -435,7 +443,7 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_m @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliFirmwareProgressLED( ZHASwitchConfigurationEntity, id_suffix="firmware_progress_led" @@ -447,7 +455,7 @@ class InovelliFirmwareProgressLED( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliRelayClickInOnOffMode( ZHASwitchConfigurationEntity, id_suffix="relay_click_in_on_off_mode" @@ -459,7 +467,7 @@ class InovelliRelayClickInOnOffMode( @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_INOVELLI, + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) class InovelliDisableDoubleTapClearNotificationsMode( ZHASwitchConfigurationEntity, id_suffix="disable_clear_notifications_double_tap" @@ -470,7 +478,9 @@ class InovelliDisableDoubleTapClearNotificationsMode( _attr_name: str = "Disable config 2x tap to clear notifications" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederLEDIndicator( ZHASwitchConfigurationEntity, id_suffix="disable_led_indicator" ): @@ -482,7 +492,9 @@ class AqaraPetFeederLEDIndicator( _attr_icon: str = "mdi:led-on" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"aqara.feeder.acn001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): """Representation of a child lock configuration entity.""" @@ -492,7 +504,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_loc @CONFIG_DIAGNOSTIC_MATCH( - channel_names=CHANNEL_ON_OFF, + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F"}, ) class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @@ -503,7 +515,9 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): _attr_icon: str = "mdi:account-lock" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatWindowDetection( ZHASwitchConfigurationEntity, id_suffix="window_detection" ): @@ -513,7 +527,9 @@ class AqaraThermostatWindowDetection( _attr_name = "Window detection" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatValveDetection( ZHASwitchConfigurationEntity, id_suffix="valve_detection" ): @@ -523,7 +539,9 @@ class AqaraThermostatValveDetection( _attr_name = "Valve detection" -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): """Representation of an Aqara thermostat child lock configuration entity.""" @@ -533,7 +551,7 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lo @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) class AqaraHeartbeatIndicator( ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" @@ -546,7 +564,7 @@ class AqaraHeartbeatIndicator( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" @@ -557,7 +575,7 @@ class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm") @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) class AqaraBuzzerManualMute( ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" @@ -570,7 +588,7 @@ class AqaraBuzzerManualMute( @CONFIG_DIAGNOSTIC_MATCH( - channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) class AqaraBuzzerManualAlarm( ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index d2da6af0126..2d4126861b4 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups @@ -19,7 +19,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service -from .api import async_get_active_network_settings, async_get_radio_type +from .api import ( + async_change_channel, + async_get_active_network_settings, + async_get_radio_type, +) from .core.const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -40,10 +44,10 @@ from .core.const import ( ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, ATTR_WARNING_DEVICE_STROBE_INTENSITY, BINDINGS, - CHANNEL_IAS_WD, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, CLUSTER_COMMANDS_SERVER, + CLUSTER_HANDLER_IAS_WD, CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, @@ -61,7 +65,7 @@ from .core.const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, + ZHA_CLUSTER_HANDLER_MSG, ZHA_CONFIG_SCHEMAS, ) from .core.gateway import EntityReference @@ -93,6 +97,7 @@ ATTR_DURATION = "duration" ATTR_GROUP = "group" ATTR_IEEE_ADDRESS = "ieee_address" ATTR_INSTALL_CODE = "install_code" +ATTR_NEW_CHANNEL = "new_channel" ATTR_SOURCE_IEEE = "source_ieee" ATTR_TARGET_IEEE = "target_ieee" ATTR_QR_CODE = "qr_code" @@ -389,7 +394,7 @@ async def websocket_get_groupable_devices( ), } for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ + if list(entity_ref.cluster_handlers.values())[ 0 ].cluster.endpoint.endpoint_id == ep_id @@ -597,7 +602,7 @@ async def websocket_reconfigure_node( connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages + hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages ) @callback @@ -1204,6 +1209,23 @@ async def websocket_restore_network_backup( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/change_channel", + vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)), + } +) +@websocket_api.async_response +async def websocket_change_channel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Migrate the Zigbee network to a new channel.""" + new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL]) + await async_change_channel(hass, new_channel=new_channel) + connection.send_result(msg[ID]) + + @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" @@ -1406,14 +1428,14 @@ def async_load_api(hass: HomeAssistant) -> None: schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], ) - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { + def _get_ias_wd_cluster_handler(zha_device): + """Get the IASWD cluster handler for a device.""" + cluster_handlers = { ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() } - return cluster_channels.get(CHANNEL_IAS_WD) + return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD) async def warning_device_squawk(service: ServiceCall) -> None: """Issue the squawk command for an IAS warning device.""" @@ -1423,11 +1445,11 @@ def async_load_api(hass: HomeAssistant) -> None: level: int = service.data[ATTR_LEVEL] if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_squawk(mode, strobe, level) else: _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", + "Squawking IASWD: %s: [%s] is missing the required IASWD cluster handler!", ATTR_IEEE, str(ieee), ) @@ -1466,13 +1488,13 @@ def async_load_api(hass: HomeAssistant) -> None: intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_start_warning( mode, strobe, level, duration, duty_mode, intensity ) else: _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", + "Warning IASWD: %s: [%s] is missing the required IASWD cluster handler!", ATTR_IEEE, str(ieee), ) @@ -1527,6 +1549,7 @@ def async_load_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_network_backups) websocket_api.async_register_command(hass, websocket_create_network_backup) websocket_api.async_register_command(hass, websocket_restore_network_backup) + websocket_api.async_register_command(hass, websocket_change_channel) @callback diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 3ab2a35bf1e..2133c8550da 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -163,7 +163,7 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) -class ZoneStorageCollection(collection.StorageCollection): +class ZoneStorageCollection(collection.DictStorageCollection): """Zone collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -178,10 +178,10 @@ class ZoneStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - return {**data, **update_data} + return {**item, **update_data} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -198,7 +198,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = ZoneStorageCollection( storage.Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await storage_collection.async_load() - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a2d729e22dc..66839026dd4 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Coroutine +from contextlib import suppress from typing import Any from async_timeout import timeout @@ -792,7 +793,11 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: for task in platform_setup_tasks: task.cancel() - await asyncio.gather(listen_task, start_client_task, *platform_setup_tasks) + tasks = (listen_task, start_client_task, *platform_setup_tasks) + await asyncio.gather(*tasks, return_exceptions=True) + for task in tasks: + with suppress(asyncio.CancelledError): + await task if client.connected: await client.disconnect() diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91b1e2a7157..29e0dcf9e06 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,8 +82,8 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_update_data_collection_preference, get_device_id, - update_data_collection_preference, ) DATA_UNSUBSCRIBE = "unsubs" @@ -1860,7 +1860,7 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - update_data_collection_preference(hass, entry, opted_in) + async_update_data_collection_preference(hass, entry, opted_in) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 2db82d38d62..e743284abdd 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -5,7 +5,7 @@ from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -13,6 +13,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN, LOGGER +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 @@ -26,6 +28,17 @@ async def async_setup_entry( """Set up Z-Wave button from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + @callback + def async_add_button(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave Button.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "notification idle": + entities.append(ZWaveNotificationIdleButton(config_entry, driver, info)) + + async_add_entities(entities) + @callback def async_add_ping_button_entity(node: ZwaveNode) -> None: """Add ping button entity.""" @@ -41,6 +54,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{BUTTON_DOMAIN}", + async_add_button, + ) + ) + class ZWaveNodePingButton(ButtonEntity): """Representation of a ping button entity.""" @@ -88,3 +109,25 @@ class ZWaveNodePingButton(ButtonEntity): async def async_press(self) -> None: """Press the button.""" self.hass.async_create_task(self.node.async_ping()) + + +class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): + """Button to idle Notification CC values.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveNotificationIdleButton entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = self.generate_name( + include_value_name=True, name_prefix="Idle" + ) + self._attr_unique_id = f"{self._attr_unique_id}.notification_idle" + + async def async_press(self) -> None: + """Press the button.""" + await self.info.node.async_manually_idle_notification_value( + self.info.primary_value + ) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4704718c804..686a186a7cb 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -51,7 +51,7 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, driver, info)) - elif info.platform_hint == "window_shutter_tilt": + 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)) @@ -99,6 +99,12 @@ def zwave_tilt_to_percent(value: int) -> int: class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave Cover device.""" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + def __init__( self, config_entry: ConfigEntry, @@ -108,11 +114,20 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): """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) + ) + + 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 in ("window_shutter", "window_shutter_tilt"): + if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint == "window_blind": + if self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND @property @@ -153,28 +168,13 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" - cover_property = ( - self.get_zwave_value(COVER_OPEN_PROPERTY) - or self.get_zwave_value(COVER_UP_PROPERTY) - or self.get_zwave_value(COVER_ON_PROPERTY) - ) - if cover_property: - # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(cover_property, False) + 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) class ZWaveTiltCover(ZWaveCover): - """Representation of a Z-Wave Cover device with tilt.""" - - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) + """Representation of a Z-Wave cover device with tilt.""" def __init__( self, @@ -184,8 +184,15 @@ class ZWaveTiltCover(ZWaveCover): ) -> None: """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - self.data_template = cast( + + 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 ) @property @@ -194,19 +201,18 @@ class ZWaveTiltCover(ZWaveCover): None is unknown, 0 is closed, 100 is fully open. """ - value = self.data_template.current_tilt_value(self.info.platform_data) + value = self._current_tilt_value if value is None or value.value is None: return None return zwave_tilt_to_percent(int(value.value)) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt_value = self.data_template.current_tilt_value(self.info.platform_data) - if tilt_value: - await self.info.node.async_set_value( - tilt_value, - percent_to_zwave_tilt(kwargs[ATTR_TILT_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.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 36295a64558..a43482e3e90 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -147,6 +147,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_key_name: set[str | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | 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 @dataclass @@ -345,7 +347,7 @@ DISCOVERY_SCHEMAS = [ # Fibaro Shutter Fibaro FGR222 ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter_tilt", + hint="shutter_tilt", manufacturer_id={0x010F}, product_id={0x1000, 0x1001}, product_type={0x0301, 0x0302}, @@ -369,7 +371,7 @@ DISCOVERY_SCHEMAS = [ # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x0159}, product_id={0x0052, 0x0053}, product_type={0x0003}, @@ -378,7 +380,7 @@ DISCOVERY_SCHEMAS = [ # Graber/Bali/Spring Fashion Covers ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x026E}, product_id={0x5A31}, product_type={0x4353}, @@ -387,7 +389,7 @@ DISCOVERY_SCHEMAS = [ # iBlinds v2 window blind motor ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x0287}, product_id={0x000D}, product_type={0x0003}, @@ -396,7 +398,7 @@ DISCOVERY_SCHEMAS = [ # Merten 507801 Connect Roller Shutter ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x007A}, product_id={0x0001}, product_type={0x8003}, @@ -412,7 +414,7 @@ DISCOVERY_SCHEMAS = [ # Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x007A}, product_id={0x0001}, product_type={0x8003}, @@ -805,7 +807,7 @@ DISCOVERY_SCHEMAS = [ # window coverings ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_cover", + hint="cover", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", @@ -897,6 +899,17 @@ DISCOVERY_SCHEMAS = [ type={ValueType.NUMBER}, ), ), + # button + # Notification CC idle + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + hint="notification idle", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + type={ValueType.NUMBER}, + any_available_states={(0, "idle")}, + ), + ), ] @@ -1072,6 +1085,16 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False + # check available states + if ( + schema.any_available_states is not None + and value.metadata.states is not None + and not any( + str(key) in value.metadata.states and value.metadata.states[str(key)] == val + for key, val in schema.any_available_states + ) + ): + return False return True diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index d856e987af7..6c54a464837 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -92,7 +92,6 @@ def value_matches_matcher( ) -@callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """Get the value ID and optional state key from a unique ID. @@ -106,7 +105,6 @@ def get_value_id_from_unique_id(unique_id: str) -> str | None: return None -@callback def get_state_key_from_unique_id(unique_id: str) -> int | None: """Get the state key from a unique ID.""" # If the unique ID has more than two parts, it's a special unique ID. If the last @@ -119,7 +117,6 @@ def get_state_key_from_unique_id(unique_id: str) -> int | None: return None -@callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" return value.value if value else None @@ -132,7 +129,7 @@ async def async_enable_statistics(driver: Driver) -> None: @callback -def update_data_collection_preference( +def async_update_data_collection_preference( hass: HomeAssistant, entry: ConfigEntry, preference: bool ) -> None: """Update data collection preference on config entry.""" @@ -141,7 +138,6 @@ def update_data_collection_preference( hass.config_entries.async_update_entry(entry, data=new_data) -@callback def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: """Return the base unique ID for an entity that is not based on a value.""" return f"{driver.controller.home_id}.{node.node_id}" @@ -152,13 +148,11 @@ def get_unique_id(driver: Driver, value_id: str) -> str: return f"{driver.controller.home_id}.{value_id}" -@callback def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]: """Get device registry identifier for Z-Wave node.""" return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}") -@callback def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None: """Get extended device registry identifier for Z-Wave node.""" if None in (node.manufacturer_id, node.product_type, node.product_id): @@ -171,7 +165,6 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None ) -@callback def get_home_and_node_id_from_device_entry( device_entry: dr.DeviceEntry, ) -> tuple[str, int] | None: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index d41ee0272a9..8452ba2ed32 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 1c6824920c1..47a16ee1273 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, CommandStatus -from zwave_js_server.exceptions import SetValueFailed +from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ValueDataType, get_value_id_str @@ -604,13 +604,16 @@ class ZWaveServices: ): new_value = str(new_value) - success = await async_multicast_set_value( - client=client, - new_value=new_value, - value_data=value, - nodes=None if broadcast else list(nodes), - options=options, - ) + try: + success = await async_multicast_set_value( + client=client, + new_value=new_value, + value_data=value, + nodes=None if broadcast else list(nodes), + options=options, + ) + except FailedZWaveCommand as err: + raise HomeAssistantError("Unable to set value via multicast") from err if success is False: raise HomeAssistantError( diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 388a8c2c1d4..1a4d9cccbe4 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.3.6", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.4.2", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2b..0a5da91d942 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ from .helpers import ( ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 454cfeade27..adbb2f80f64 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -20,7 +20,7 @@ from . import data_entry_flow, loader from .backports.enum import StrEnum from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform -from .core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback from .data_entry_flow import FlowResult from .exceptions import ( ConfigEntryAuthFailed, @@ -294,10 +294,10 @@ class ConfigEntry: self.disabled_by = disabled_by # Supports unload - self.supports_unload = False + self.supports_unload: bool | None = None # Supports remove device - self.supports_remove_device = False + self.supports_remove_device: bool | None = None # Listeners to call on update self.update_listeners: list[ @@ -310,8 +310,10 @@ class ConfigEntry: # Function to cancel a scheduled retry self._async_cancel_retry_setup: Callable[[], Any] | None = None - # Hold list for functions to call on unload. - self._on_unload: list[CALLBACK_TYPE] | None = None + # Hold list for actions to call on unload. + self._on_unload: list[ + Callable[[], Coroutine[Any, Any, None] | None] + ] | None = None # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() @@ -338,10 +340,12 @@ class ConfigEntry: if self.domain == integration.domain: self.async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) - self.supports_unload = await support_entry_unload(hass, self.domain) - self.supports_remove_device = await support_remove_from_device( - hass, self.domain - ) + if self.supports_unload is None: + self.supports_unload = await support_entry_unload(hass, self.domain) + if self.supports_remove_device is None: + self.supports_remove_device = await support_remove_from_device( + hass, self.domain + ) try: component = integration.get_component() @@ -383,7 +387,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -395,7 +399,7 @@ class ConfigEntry: self.domain, error_reason, ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) result = False except ConfigEntryAuthFailed as ex: message = str(ex) @@ -410,7 +414,7 @@ class ConfigEntry: self.domain, auth_message, ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: @@ -461,7 +465,7 @@ class ConfigEntry: EVENT_HOMEASSISTANT_STARTED, setup_again ) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) return # pylint: disable-next=broad-except except (asyncio.CancelledError, SystemExit, Exception): @@ -544,10 +548,9 @@ class ConfigEntry: if result and integration.domain == self.domain: self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) - await self._async_process_on_unload() + await self._async_process_on_unload(hass) - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +631,14 @@ class ConfigEntry: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain @@ -676,17 +678,20 @@ class ConfigEntry: } @callback - def async_on_unload(self, func: CALLBACK_TYPE) -> None: + def async_on_unload( + self, func: Callable[[], Coroutine[Any, Any, None] | None] + ) -> None: """Add a function to call when config entry is unloaded.""" if self._on_unload is None: self._on_unload = [] self._on_unload.append(func) - async def _async_process_on_unload(self) -> None: + async def _async_process_on_unload(self, hass: HomeAssistant) -> None: """Process the on_unload callbacks and wait for pending tasks.""" if self._on_unload is not None: while self._on_unload: - self._on_unload.pop()() + if job := self._on_unload.pop()(): + self._tasks.add(hass.async_create_task(job)) if not self._tasks and not self._background_tasks: return @@ -1353,26 +1358,6 @@ class ConfigEntries: self.hass, SIGNAL_CONFIG_ENTRY_CHANGED, change_type, entry ) - @callback - def async_setup_platforms( - self, entry: ConfigEntry, platforms: Iterable[Platform | str] - ) -> None: - """Forward the setup of an entry to platforms.""" - report( - ( - "called async_setup_platforms instead of awaiting" - " async_forward_entry_setups; this will fail in version 2023.3" - ), - # Raise this to warning once all core integrations have been migrated - level=logging.WARNING, - error_if_core=False, - ) - for platform in platforms: - self.hass.async_create_task( - self.async_forward_entry_setup(entry, platform), - f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", - ) - async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: @@ -1967,7 +1952,9 @@ class EntityRegistryDisabledHandler: self._remove_call_later() self._remove_call_later = async_call_later( - self.hass, RELOAD_AFTER_UPDATE_DELAY, self._handle_reload + self.hass, + RELOAD_AFTER_UPDATE_DELAY, + HassJob(self._handle_reload, cancel_on_shutdown=True), ) async def _handle_reload(self, _now: Any) -> None: diff --git a/homeassistant/const.py b/homeassistant/const.py index 2fc41b74376..3eb31a1af78 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,8 +7,8 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "6" +MINOR_VERSION: Final = 5 +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) diff --git a/homeassistant/core.py b/homeassistant/core.py index 78ceb620e53..f7cfcf44205 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -217,13 +217,25 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target", "name") + __slots__ = ("job_type", "target", "name", "_cancel_on_shutdown") - def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: + def __init__( + self, + target: Callable[_P, _R_co], + name: str | None = None, + *, + cancel_on_shutdown: bool | None = None, + ) -> None: """Create a job object.""" self.target = target self.name = name self.job_type = _get_hassjob_callable_job_type(target) + self._cancel_on_shutdown = cancel_on_shutdown + + @property + def cancel_on_shutdown(self) -> bool | None: + """Return if the job should be cancelled on shutdown.""" + return self._cancel_on_shutdown def __repr__(self) -> str: """Return the job.""" @@ -505,12 +517,14 @@ class HomeAssistant: return task - def create_task(self, target: Coroutine[Any, Any, Any]) -> None: + def create_task( + self, target: Coroutine[Any, Any, Any], name: str | None = None + ) -> None: """Add task to the executor pool. target: target to call. """ - self.loop.call_soon_threadsafe(self.async_create_task, target) + self.loop.call_soon_threadsafe(self.async_create_task, target, name) @callback def async_create_task( @@ -728,6 +742,7 @@ class HomeAssistant: self._tasks.add(task) task.add_done_callback(self._tasks.remove) task.cancel() + self._cancel_cancellable_timers() self.exit_code = exit_code @@ -812,6 +827,20 @@ class HomeAssistant: if self._stopped is not None: self._stopped.set() + def _cancel_cancellable_timers(self) -> None: + """Cancel timer handles marked as cancellable.""" + # pylint: disable-next=protected-access + handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] + for handle in handles: + if ( + not handle.cancelled() + and (args := handle._args) # pylint: disable=protected-access + # pylint: disable-next=unidiomatic-typecheck + and type(job := args[0]) is HassJob + and job.cancel_on_shutdown + ): + handle.cancel() + def _async_log_running_tasks(self, stage: int) -> None: """Log all running tasks.""" for task in self._tasks: @@ -1847,7 +1876,10 @@ class ServiceRegistry: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error executing service: %s", service_call) - self._hass.async_create_task(catch_exceptions()) + self._hass.async_create_task( + catch_exceptions(), + f"service call background {service_call.domain}.{service_call.service}", + ) async def _execute_service( self, handler: Service, service_call: ServiceCall diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 347ab89e452..e213814f52c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -48,7 +48,7 @@ RESULT_TYPE_MENU = "menu" EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" -@dataclass +@dataclass(slots=True) class BaseServiceInfo: """Base class for discovery ServiceInfo.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 6cc93ef4f66..bfc96eabfdf 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -32,7 +32,7 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") -@dataclass +@dataclass(slots=True) class ConditionError(HomeAssistantError): """Error during condition evaluation.""" @@ -52,7 +52,7 @@ class ConditionError(HomeAssistantError): return "\n".join(list(self.output(indent=0))) -@dataclass +@dataclass(slots=True) class ConditionErrorMessage(ConditionError): """Condition error message.""" @@ -64,7 +64,7 @@ class ConditionErrorMessage(ConditionError): yield self._indent(indent, f"In '{self.type}' condition: {self.message}") -@dataclass +@dataclass(slots=True) class ConditionErrorIndex(ConditionError): """Condition error with index.""" @@ -87,7 +87,7 @@ class ConditionErrorIndex(ConditionError): yield from self.error.output(indent + 1) -@dataclass +@dataclass(slots=True) class ConditionErrorContainer(ConditionError): """Condition error with subconditions.""" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index fc295084315..24215a8a0c4 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -327,6 +327,21 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "qingping", "service_data_uuid": "0000fdcd-0000-1000-8000-00805f9b34fb", }, + { + "domain": "rapt_ble", + "manufacturer_data_start": [ + 80, + 84, + ], + "manufacturer_id": 16722, + }, + { + "domain": "rapt_ble", + "manufacturer_data_start": [ + 71, + ], + "manufacturer_id": 17739, + }, { "connectable": False, "domain": "ruuvitag_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9e..066fb6fb8b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -39,6 +39,8 @@ FLOWS = { "ambient_station", "android_ip_webcam", "androidtv", + "androidtv_remote", + "anova", "anthemav", "apcupsd", "apple_tv", @@ -67,6 +69,7 @@ FLOWS = { "braviatv", "broadlink", "brother", + "brottsplatskartan", "brunt", "bsblan", "bthome", @@ -79,7 +82,6 @@ FLOWS = { "coinbase", "control4", "coolmaster", - "coronavirus", "cpuspeed", "crownstone", "daikin", @@ -339,6 +341,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", @@ -348,6 +351,7 @@ FLOWS = { "rainbird", "rainforest_eagle", "rainmachine", + "rapt_ble", "rdw", "recollect_waste", "renault", @@ -358,6 +362,7 @@ FLOWS = { "ring", "risco", "rituals_perfume_genie", + "roborock", "roku", "roomba", "roon", @@ -398,6 +403,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", @@ -479,6 +485,7 @@ FLOWS = { "vilfo", "vizio", "vlc_telnet", + "voip", "volumio", "volvooncall", "vulcan", @@ -495,7 +502,9 @@ FLOWS = { "wiz", "wled", "wolflink", + "workday", "ws66i", + "wyoming", "xbox", "xiaomi_aqara", "xiaomi_ble", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 333db76d4f3..adcc32fe8d9 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -330,11 +330,19 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "nuki", "hostname": "nuki_bridge_*", }, + { + "domain": "obihai", + "macaddress": "9CADEF*", + }, { "domain": "oncue", "hostname": "kohlergen*", "macaddress": "00146F*", }, + { + "domain": "onvif", + "registered_devices": True, + }, { "domain": "overkiz", "hostname": "gateway*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8b72a8499a7..d85765aec4c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,17 +241,29 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, + "androidtv_remote": { + "name": "Android TV Remote", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "anel_pwrctrl": { "name": "Anel NET-PwrCtrl", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" }, + "anova": { + "name": "Anova", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "anthemav": { "name": "Anthem A/V Receivers", "integration_type": "hub", @@ -293,7 +305,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_push", - "name": "HomeKit" + "name": "HomeKit Bridge" }, "ibeacon": { "integration_type": "hub", @@ -392,6 +404,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "assist_pipeline": { + "name": "Assist pipeline", + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_push" + }, "asterisk": { "name": "Asterisk", "integrations": { @@ -657,7 +675,7 @@ "brottsplatskartan": { "name": "Brottsplatskartan", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "browser": { @@ -875,12 +893,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "coronavirus": { - "name": "Coronavirus (COVID-19)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "cozytouch": { "name": "Atlantic Cozytouch", "integration_type": "virtual", @@ -3287,12 +3299,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, @@ -3389,6 +3395,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "monessen": { + "name": "Monessen", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "monoprice": { "name": "Monoprice 6-Zone Amplifier", "integration_type": "hub", @@ -4310,7 +4321,7 @@ "qbittorrent": { "name": "qBittorrent", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "qingping": { @@ -4419,6 +4430,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "rapt_ble": { + "name": "RAPT Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "raspberry_pi": { "name": "Raspberry Pi", "integrations": { @@ -4579,8 +4596,9 @@ }, "roborock": { "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" }, "rocketchat": { "name": "Rocket.Chat", @@ -5055,7 +5073,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { @@ -6041,7 +6059,7 @@ }, "vizio": { "name": "VIZIO SmartCast", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6062,18 +6080,18 @@ } } }, - "voice_assistant": { - "name": "Voice Assistant", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" - }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push" }, + "voip": { + "name": "Voice over IP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "volkszaehler": { "name": "Volkszaehler", "integration_type": "hub", @@ -6213,7 +6231,7 @@ "workday": { "name": "Workday", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "worldclock": { @@ -6246,6 +6264,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "wyoming": { + "name": "Wyoming Protocol", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "x10": { "name": "Heyu X10", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2f3dbaefb17..1771d9d63bf 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_androidtvremote2._tcp.local.": [ + { + "domain": "androidtv_remote", + }, + ], "_api._tcp.local.": [ { "domain": "baf", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 10ed9fdd65d..499e548ce90 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -8,11 +8,9 @@ from typing import Any, cast import attr from homeassistant.core import HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util import slugify from . import device_registry as dr, entity_registry as er -from .frame import report from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -216,7 +214,7 @@ class AreaRegistry: if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) + new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] if normalized_name is not None: self._normalized_name_area_idx[ normalized_name @@ -282,19 +280,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> AreaRegistry: - """Get area registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access area registry, use async_get" - " instead" - ) - return async_get(hass) - - def normalize_area_name(area_name: str) -> str: """Normalize an area name by removing whitespace and case folding.""" return area_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 9da6f84207a..a0c47a04903 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from itertools import groupby import logging from operator import attrgetter -from typing import Any, cast +from typing import Any, Generic, TypedDict, TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -32,8 +32,12 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" +_ItemT = TypeVar("_ItemT") +_StoreT = TypeVar("_StoreT", bound="SerializedStorageCollection") +_StorageCollectionT = TypeVar("_StorageCollectionT", bound="StorageCollection") -@dataclass + +@dataclass(slots=True) class CollectionChangeSet: """Class to represent a change set. @@ -121,41 +125,42 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection(ABC): +class ObservableCollection(ABC, Generic[_ItemT]): """Base collection type that can be observed.""" - def __init__( - self, logger: logging.Logger, id_manager: IDManager | None = None - ) -> None: + def __init__(self, id_manager: IDManager | None) -> None: """Initialize the base collection.""" - self.logger = logger self.id_manager = id_manager or IDManager() - self.data: dict[str, dict] = {} + self.data: dict[str, _ItemT] = {} self.listeners: list[ChangeListener] = [] self.change_set_listeners: list[ChangeSetListener] = [] self.id_manager.add_collection(self.data) @callback - def async_items(self) -> list[dict]: + def async_items(self) -> list[_ItemT]: """Return list of items in collection.""" return list(self.data.values()) @callback - def async_add_listener(self, listener: ChangeListener) -> None: + def async_add_listener(self, listener: ChangeListener) -> Callable[[], None]: """Add a listener. Will be called with (change_type, item_id, updated_config). """ self.listeners.append(listener) + return lambda: self.listeners.remove(listener) @callback - def async_add_change_set_listener(self, listener: ChangeSetListener) -> None: + def async_add_change_set_listener( + self, listener: ChangeSetListener + ) -> Callable[[], None]: """Add a listener for a full change set. Will be called with [(change_type, item_id, updated_config), ...] """ self.change_set_listeners.append(listener) + return lambda: self.change_set_listeners.remove(listener) async def notify_changes(self, change_sets: Iterable[CollectionChangeSet]) -> None: """Notify listeners of a change.""" @@ -172,9 +177,18 @@ class ObservableCollection(ABC): ) -class YamlCollection(ObservableCollection): +class YamlCollection(ObservableCollection[dict]): """Offer a collection based on static data.""" + def __init__( + self, + logger: logging.Logger, + id_manager: IDManager | None = None, + ) -> None: + """Initialize the storage collection.""" + super().__init__(id_manager) + self.logger = logger + @staticmethod def create_entity( entity_class: type[CollectionEntity], config: ConfigType @@ -212,17 +226,22 @@ class YamlCollection(ObservableCollection): await self.notify_changes(change_sets) -class StorageCollection(ObservableCollection, ABC): +class SerializedStorageCollection(TypedDict): + """Serialized storage collection.""" + + items: list[dict[str, Any]] + + +class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): """Offer a CRUD interface on top of JSON storage.""" def __init__( self, - store: Store, - logger: logging.Logger, + store: Store[_StoreT], id_manager: IDManager | None = None, ) -> None: """Initialize the storage collection.""" - super().__init__(logger, id_manager) + super().__init__(id_manager) self.store = store @staticmethod @@ -237,19 +256,17 @@ class StorageCollection(ObservableCollection, ABC): """Home Assistant object.""" return self.store.hass - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> _StoreT | None: """Load the data.""" - return cast(dict | None, await self.store.async_load()) + return await self.store.async_load() async def async_load(self) -> None: """Load the storage Manager.""" - raw_storage = await self._async_load_data() - - if raw_storage is None: - raw_storage = {"items": []} + if not (raw_storage := await self._async_load_data()): + return for item in raw_storage["items"]: - self.data[item[CONF_ID]] = item + self.data[item[CONF_ID]] = self._deserialize_item(item) await self.notify_changes( [ @@ -268,21 +285,35 @@ class StorageCollection(ObservableCollection, ABC): """Suggest an ID based on the config.""" @abstractmethod - async def _update_data(self, data: dict, update_data: dict) -> dict: - """Return a new updated data object.""" + async def _update_data(self, item: _ItemT, update_data: dict) -> _ItemT: + """Return a new updated item.""" - async def async_create_item(self, data: dict) -> dict: + @abstractmethod + def _create_item(self, item_id: str, data: dict) -> _ItemT: + """Create an item from validated config.""" + + @abstractmethod + def _deserialize_item(self, data: dict) -> _ItemT: + """Create an item from its serialized representation.""" + + @abstractmethod + def _serialize_item(self, item_id: str, item: _ItemT) -> dict: + """Return the serialized representation of an item for storing. + + The serialized representation must include the item_id in the "id" key. + """ + + async def async_create_item(self, data: dict) -> _ItemT: """Create a new item.""" - item = await self._process_create_data(data) - item[CONF_ID] = self.id_manager.generate_id(self._get_suggested_id(item)) - self.data[item[CONF_ID]] = item + validated_data = await self._process_create_data(data) + item_id = self.id_manager.generate_id(self._get_suggested_id(validated_data)) + item = self._create_item(item_id, validated_data) + self.data[item_id] = item self._async_schedule_save() - await self.notify_changes( - [CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item)] - ) + await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)]) return item - async def async_update_item(self, item_id: str, updates: dict) -> dict: + async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: """Update item.""" if item_id not in self.data: raise ItemNotFound(item_id) @@ -315,13 +346,44 @@ class StorageCollection(ObservableCollection, ABC): @callback def _async_schedule_save(self) -> None: - """Schedule saving the area registry.""" + """Schedule saving the collection.""" self.store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: - """Return data of area registry to store in a file.""" - return {"items": list(self.data.values())} + def _base_data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible data for storing to file.""" + return { + "items": [ + self._serialize_item(item_id, item) + for item_id, item in self.data.items() + ] + } + + @abstractmethod + @callback + def _data_to_save(self) -> _StoreT: + """Return JSON-compatible date for storing to file.""" + + +class DictStorageCollection(StorageCollection[dict, SerializedStorageCollection]): + """A specialized StorageCollection where the items are untyped dicts.""" + + def _create_item(self, item_id: str, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return {CONF_ID: item_id} | data + + def _deserialize_item(self, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return data + + def _serialize_item(self, item_id: str, item: dict) -> dict: + """Return the serialized representation of an item for storing.""" + return item + + @callback + def _data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible date for storing to file.""" + return self._base_data_to_save() class IDLessCollection(YamlCollection): @@ -429,12 +491,12 @@ def sync_entity_lifecycle( collection.async_add_change_set_listener(_collection_changed) -class StorageCollectionWebsocket: +class StorageCollectionWebsocket(Generic[_StorageCollectionT]): """Class to expose storage collection management over websocket.""" def __init__( self, - storage_collection: StorageCollection, + storage_collection: _StorageCollectionT, api_prefix: str, model_name: str, create_schema: dict, @@ -517,6 +579,7 @@ class StorageCollectionWebsocket: ), ) + @callback def ws_list_item( self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: @@ -587,3 +650,7 @@ class StorageCollectionWebsocket: ) connection.send_result(msg["id"]) + + +class DictStorageCollectionWebsocket(StorageCollectionWebsocket[DictStorageCollection]): + """Class to expose storage collection management over websocket.""" diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index dd536956a83..2df8965de34 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -44,6 +44,7 @@ class Debouncer(Generic[_R_co]): function, f"debouncer cooldown={cooldown}, immediate={immediate}" ) ) + self._shutdown_requested = False @property def function(self) -> Callable[[], _R_co] | None: @@ -62,6 +63,11 @@ class Debouncer(Generic[_R_co]): async def async_call(self) -> None: """Call the function.""" + if self._shutdown_requested: + self.logger.warning( + "Debouncer call ignored as shutdown has been requested." + ) + return assert self._job is not None if self._timer_task: @@ -115,6 +121,11 @@ class Debouncer(Generic[_R_co]): # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and prevent new runs.""" + self._shutdown_requested = True + self.async_cancel() + @callback def async_cancel(self) -> None: """Cancel any scheduled call.""" @@ -137,4 +148,7 @@ class Debouncer(Generic[_R_co]): @callback def _schedule_timer(self) -> None: """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) + if not self._shutdown_requested: + self._timer_task = self.hass.loop.call_later( + self.cooldown, self._on_debounce + ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b72a1878651..29e64639722 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -13,7 +13,6 @@ from homeassistant.backports.enum import StrEnum from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, RequiredParameterMissing -from homeassistant.loader import bind_hass from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util @@ -749,19 +748,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> DeviceRegistry: - """Get device registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access device registry, use async_get" - " instead" - ) - return async_get(hass) - - @callback def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[DeviceEntry]: """Return entries that match an area.""" diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701a..7045966c529 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -68,7 +67,10 @@ def discover( hass_config: ConfigType, ) -> None: """Fire discovery event. Can ensure a component is loaded.""" - hass.add_job(async_discover(hass, service, discovered, component, hass_config)) + hass.create_task( + async_discover(hass, service, discovered, component, hass_config), + f"discover {service} {component} {discovered}", + ) @bind_hass @@ -105,17 +107,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) @@ -128,8 +130,9 @@ def load_platform( hass_config: ConfigType, ) -> None: """Load a component and platform dynamically.""" - hass.add_job( - async_load_platform(hass, component, platform, discovered, hass_config) + hass.create_task( + async_load_platform(hass, component, platform, discovered, hass_config), + f"discovery load_platform {component} {platform}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a8..9dbd5d4ad67 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -205,7 +205,7 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass +@dataclass(slots=True) class EntityDescription: """A class that describes Home Assistant entities.""" @@ -249,6 +249,10 @@ class Entity(ABC): # If we reported this entity is updated while disabled _disabled_reported = False + # If we reported this entity is using async_update_ha_state, while + # it should be using async_write_ha_state. + _async_update_ha_state_reported = False + # Protect for multiple updates _update_staged = False @@ -551,6 +555,19 @@ class Entity(ABC): except Exception: # pylint: disable=broad-except _LOGGER.exception("Update for %s fails", self.entity_id) return + elif not self._async_update_ha_state_reported: + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is using self.async_update_ha_state(), without" + " enabling force_update. Instead it should use" + " self.async_write_ha_state(), please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._async_update_ha_state_reported = True self._async_write_ha_state() @@ -698,7 +715,13 @@ class Entity(ABC): If state is changed more than once before the ha state change task has been executed, the intermediate state transitions will be missed. """ - self.hass.add_job(self.async_update_ha_state(force_refresh)) + if force_refresh: + self.hass.create_task( + self.async_update_ha_state(force_refresh), + f"Entity {self.entity_id} schedule update ha state", + ) + else: + self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) @callback def async_schedule_update_ha_state(self, force_refresh: bool = False) -> None: @@ -720,6 +743,15 @@ class Entity(ABC): else: self.async_write_ha_state() + @callback + def _async_slow_update_warning(self) -> None: + """Log a warning if update is taking too long.""" + _LOGGER.warning( + "Update of %s is taking over %s seconds", + self.entity_id, + SLOW_UPDATE_WARNING, + ) + async def async_device_update(self, warning: bool = True) -> None: """Process 'update' or 'async_update' from entity. @@ -727,42 +759,33 @@ class Entity(ABC): """ if self._update_staged: return + + hass = self.hass + assert hass is not None + + if hasattr(self, "async_update"): + coro: asyncio.Future[None] = self.async_update() + elif hasattr(self, "update"): + coro = hass.async_add_executor_job(self.update) + else: + return + self._update_staged = True # Process update sequential if self.parallel_updates: await self.parallel_updates.acquire() - try: - task: asyncio.Future[None] - if hasattr(self, "async_update"): - task = self.hass.async_create_task( - self.async_update(), f"Entity async update {self.entity_id}" - ) - elif hasattr(self, "update"): - task = self.hass.async_add_executor_job(self.update) - else: - return - - if not warning: - await task - return - - finished, _ = await asyncio.wait([task], timeout=SLOW_UPDATE_WARNING) - - for done in finished: - if exc := done.exception(): - raise exc - return - - _LOGGER.warning( - "Update of %s is taking over %s seconds", - self.entity_id, - SLOW_UPDATE_WARNING, + if warning: + update_warn = hass.loop.call_later( + SLOW_UPDATE_WARNING, self._async_slow_update_warning ) - await task + try: + await coro finally: self._update_staged = False + if warning: + update_warn.cancel() if self.parallel_updates: self.parallel_updates.release() @@ -945,25 +968,6 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" @@ -997,7 +1001,7 @@ class Entity(ABC): return report_issue -@dataclass +@dataclass(slots=True) class ToggleEntityDescription(EntityDescription): """A class that describes toggle entities.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 0c43dddec60..dc101a10b05 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -109,12 +109,22 @@ class EntityComponent(Generic[_EntityT]): return entity_obj # type: ignore[return-value] return None + def register_shutdown(self) -> None: + """Register shutdown on Home Assistant STOP event. + + Note: this is only required if the integration never calls + `setup` or `async_setup`. + """ + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + def setup(self, config: ConfigType) -> None: """Set up a full entity component. This doesn't block the executor to protect from deadlocks. """ - self.hass.add_job(self.async_setup(config)) + self.hass.create_task( + self.async_setup(config), f"EntityComponent setup {self.domain}" + ) async def async_setup(self, config: ConfigType) -> None: """Set up a full entity component. @@ -124,7 +134,7 @@ class EntityComponent(Generic[_EntityT]): This method must be run in the event loop. """ - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) + self.register_shutdown() self.config = config diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9cb119b81b4..d8c5a6c1cf6 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -42,13 +42,11 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import MaxLengthExceeded -from homeassistant.loader import bind_hass from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.json import format_unserializable_data from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data from .typing import UNDEFINED, UndefinedType @@ -1077,19 +1075,6 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() -@bind_hass -async def async_get_registry(hass: HomeAssistant) -> EntityRegistry: - """Get entity registry. - - This is deprecated and will be removed in the future. Use async_get instead. - """ - report( - "uses deprecated `async_get_registry` to access entity registry, use async_get" - " instead" - ) - return async_get(hass) - - @callback def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24f..057e8f0955e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ class EntityFilter: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + 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 def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - - -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) - - -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + if globs is None: + return None + + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + + if not translated_patterns: + return None + + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 44a9cb087e3..0a51d6660ae 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -66,7 +66,7 @@ RANDOM_MICROSECOND_MAX = 500000 _P = ParamSpec("_P") -@dataclass +@dataclass(slots=True) class TrackStates: """Class for keeping track of states being tracked. @@ -80,7 +80,7 @@ class TrackStates: domains: set[str] -@dataclass +@dataclass(slots=True) class TrackTemplate: """Class for keeping track of a template with variables. @@ -94,7 +94,7 @@ class TrackTemplate: rate_limit: timedelta | None = None -@dataclass +@dataclass(slots=True) class TrackTemplateResult: """Class for result of template tracking. @@ -1295,7 +1295,12 @@ def async_track_point_in_time( """Convert passed in UTC now to local now.""" hass.async_run_hass_job(job, dt_util.as_local(utc_now)) - return async_track_point_in_utc_time(hass, utc_converter, point_in_time) + track_job = HassJob( + utc_converter, + name=f"{job.name} UTC converter", + cancel_on_shutdown=job.cancel_on_shutdown, + ) + return async_track_point_in_utc_time(hass, track_job, point_in_time) track_point_in_time = threaded_listener_factory(async_track_point_in_time) @@ -1399,12 +1404,15 @@ def async_track_time_interval( interval: timedelta, *, name: str | None = None, + cancel_on_shutdown: bool | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] - job = HassJob(action, f"track time interval {interval}") + job = HassJob( + action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown + ) def next_interval() -> datetime: """Return the next interval.""" @@ -1422,11 +1430,13 @@ def async_track_time_interval( hass.async_run_hass_job(job, now) if name: - job_name = f"{name}: track time interval {interval}" + job_name = f"{name}: track time interval {interval} {action}" else: - job_name = f"track time interval {interval}" + job_name = f"track time interval {interval} {action}" - interval_listener_job = HassJob(interval_listener, job_name) + interval_listener_job = HassJob( + interval_listener, job_name, cancel_on_shutdown=cancel_on_shutdown + ) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: @@ -1548,7 +1558,7 @@ def async_track_utc_time_change( """Add a listener that will fire if time matches a pattern.""" # We do not have to wrap the function with time pattern matching logic # if no pattern given - if all(val is None for val in (hour, minute, second)): + if all(val is None or val == "*" for val in (hour, minute, second)): # Previously this relied on EVENT_TIME_FIRED # which meant it would not fire right away because # the caller would always be misaligned with the call @@ -1573,23 +1583,30 @@ def async_track_utc_time_change( ).replace(microsecond=microsecond) time_listener: CALLBACK_TYPE | None = None + pattern_time_change_listener_job: HassJob[[datetime], Any] | None = None @callback def pattern_time_change_listener(_: datetime) -> None: """Listen for matching time_changed events.""" nonlocal time_listener + nonlocal pattern_time_change_listener_job now = time_tracker_utcnow() hass.async_run_hass_job(job, dt_util.as_local(now) if local else now) + assert pattern_time_change_listener_job is not None time_listener = async_track_point_in_utc_time( hass, - pattern_time_change_listener, + pattern_time_change_listener_job, calculate_next(now + timedelta(seconds=1)), ) + pattern_time_change_listener_job = HassJob( + pattern_time_change_listener, + "time change listener {hour}:{minute}:{second} {action}", + ) time_listener = async_track_point_in_utc_time( - hass, pattern_time_change_listener, calculate_next(dt_util.utcnow()) + hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) ) @callback diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 44ad81c73e9..beb084d8c1c 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,7 +11,11 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass -from homeassistant.util.ssl import get_default_context, get_default_no_verify_context +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .frame import warn_use @@ -56,6 +60,7 @@ def create_async_httpx_client( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, **kwargs: Any, ) -> httpx.AsyncClient: """Create a new httpx.AsyncClient with kwargs, i.e. for cookies. @@ -66,7 +71,9 @@ def create_async_httpx_client( This method must be run in the event loop. """ ssl_context = ( - get_default_context() if verify_ssl else get_default_no_verify_context() + client_context(ssl_cipher_list) + if verify_ssl + else create_no_verify_ssl_context(ssl_cipher_list) ) client = HassHttpXAsyncClient( verify=ssl_context, diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index ef05dae518b..ddaede44962 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) DATA_INTEGRATION_PLATFORMS = "integration_platforms" -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class IntegrationPlatform: """An integration platform.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 4e7dcc5a5a1..8b07c2adc9a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -65,6 +66,7 @@ async def async_handle( text_input: str | None = None, context: Context | None = None, language: str | None = None, + assistant: str | None = None, ) -> IntentResponse: """Handle an intent.""" handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -79,7 +81,14 @@ async def async_handle( language = hass.config.language intent = Intent( - hass, platform, intent_type, slots or {}, text_input, context, language + hass, + platform=platform, + intent_type=intent_type, + slots=slots or {}, + text_input=text_input, + context=context, + language=language, + assistant=assistant, ) try: @@ -208,6 +217,7 @@ def async_match_states( entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, devices: device_registry.DeviceRegistry | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -258,6 +268,14 @@ def async_match_states( states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) + if assistant is not None: + # Filter by exposure + states_and_entities = [ + (state, entity) + for state, entity in states_and_entities + if async_should_expose(hass, assistant, state.entity_id) + ] + if name is not None: if devices is None: devices = device_registry.async_get(hass) @@ -387,6 +405,7 @@ class ServiceIntentHandler(IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) @@ -496,6 +515,7 @@ class Intent: "context", "language", "category", + "assistant", ] def __init__( @@ -508,6 +528,7 @@ class Intent: context: Context, language: str, category: IntentCategory | None = None, + assistant: str | None = None, ) -> None: """Initialize an intent.""" self.hass = hass @@ -518,6 +539,7 @@ class Intent: self.context = context self.language = language self.category = category + self.assistant = assistant @callback def create_response(self) -> IntentResponse: @@ -568,7 +590,7 @@ class IntentResponseTargetType(str, Enum): CUSTOM = "custom" -@dataclass +@dataclass(slots=True) class IntentResponseTarget: """Target of the intent response.""" diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 345ec099d3f..afe2d98ed0b 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -32,7 +32,7 @@ class IssueSeverity(StrEnum): WARNING = "warning" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(slots=True, frozen=True) class IssueEntry: """Issue Registry Entry.""" diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 5545aa09f01..74ebbe5c67a 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback DOMAIN = "recorder" -@dataclass +@dataclass(slots=True) class RecorderData: """Recorder data stored in hass.data.""" diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 5101e5c69a7..653594f2808 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -27,7 +27,7 @@ class SchemaFlowStep: """Define a config or options flow step.""" -@dataclass +@dataclass(slots=True) class SchemaFlowFormStep(SchemaFlowStep): """Define a config or options flow form step.""" @@ -79,7 +79,7 @@ class SchemaFlowFormStep(SchemaFlowStep): """ -@dataclass +@dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): """Define a config or options flow menu step.""" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index e2f58e357ed..fec9d25563e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -286,6 +286,28 @@ class AreaSelector(Selector[AreaSelectorConfig]): return [vol.Schema(str)(val) for val in data] +class AssistPipelineSelectorConfig(TypedDict, total=False): + """Class to represent an assist pipeline selector config.""" + + +@SELECTORS.register("assist_pipeline") +class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]): + """Selector for an assist pipeline.""" + + selector_type = "assist_pipeline" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: AssistPipelineSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + pipeline: str = vol.Schema(str)(data) + return pipeline + + class AttributeSelectorConfig(TypedDict, total=False): """Class to represent an attribute selector config.""" @@ -659,6 +681,40 @@ class IconSelector(Selector[IconSelectorConfig]): return icon +class LanguageSelectorConfig(TypedDict, total=False): + """Class to represent an language selector config.""" + + languages: list[str] + native_name: bool + no_sort: bool + + +@SELECTORS.register("language") +class LanguageSelector(Selector[LanguageSelectorConfig]): + """Selector for an language.""" + + selector_type = "language" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("languages"): [str], + vol.Optional("native_name", default=False): cv.boolean, + vol.Optional("no_sort", default=False): cv.boolean, + } + ) + + def __init__(self, config: LanguageSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + language: str = vol.Schema(str)(data) + if "languages" in self.config and language not in self.config["languages"]: + raise vol.Invalid(f"Value {language} is not a valid option") + return language + + class LocationSelectorConfig(TypedDict, total=False): """Class to represent a location selector config.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 33c677454bc..14cf6a85a24 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -199,7 +199,7 @@ class ServiceTargetSelector: return bool(self.entity_ids or self.device_ids or self.area_ids) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class SelectedEntities: """Class to hold the selected entities.""" diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py index 3626f9b5758..906072a2d4b 100644 --- a/homeassistant/helpers/service_info/mqtt.py +++ b/homeassistant/helpers/service_info/mqtt.py @@ -7,7 +7,7 @@ from homeassistant.data_entry_flow import BaseServiceInfo ReceivePayloadType = str | bytes -@dataclass +@dataclass(slots=True) class MqttServiceInfo(BaseServiceInfo): """Prepared info from mqtt entries.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 2c2e5b2d95e..5cc2c6aa807 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -42,6 +42,7 @@ from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU # pylint: disable=no-name-in-module +import orjson import voluptuous as vol from homeassistant.const import ( @@ -150,6 +151,10 @@ CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( ) ENTITY_COUNT_GROWTH_FACTOR = 1.2 +ORJSON_PASSTHROUGH_OPTIONS = ( + orjson.OPT_PASSTHROUGH_DATACLASS | orjson.OPT_PASSTHROUGH_DATETIME +) + def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: """Return a TemplateState for a state without collecting.""" @@ -2029,9 +2034,38 @@ def from_json(value): return json_loads(value) -def to_json(value, ensure_ascii=True): +def _to_json_default(obj: Any) -> None: + """Disable custom types in json serialization.""" + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +def to_json( + value: Any, + ensure_ascii: bool = False, + pretty_print: bool = False, + sort_keys: bool = False, +) -> str: """Convert an object to a JSON string.""" - return json.dumps(value, ensure_ascii=ensure_ascii) + if ensure_ascii: + # For those who need ascii, we can't use orjson, so we fall back to the json library. + return json.dumps( + value, + ensure_ascii=ensure_ascii, + indent=2 if pretty_print else None, + sort_keys=sort_keys, + ) + + option = ( + ORJSON_PASSTHROUGH_OPTIONS + | (orjson.OPT_INDENT_2 if pretty_print else 0) + | (orjson.OPT_SORT_KEYS if sort_keys else 0) + ) + + return orjson.dumps( + value, + option=option, + default=_to_json_default, + ).decode("utf-8") @pass_context diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index 3de42f8fc98..b6b39e9c32f 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -16,6 +16,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + ATTR_ICON, CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, @@ -31,7 +34,13 @@ from . import config_validation as cv from .entity import Entity from .event import TrackTemplate, TrackTemplateResult, async_track_template_result from .script import Script, _VarsType -from .template import Template, TemplateStateFromEntityId, result_as_boolean +from .template import ( + Template, + TemplateStateFromEntityId, + attach as template_attach, + render_complex, + result_as_boolean, +) from .typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -40,6 +49,12 @@ CONF_AVAILABILITY = "availability" CONF_ATTRIBUTES = "attributes" CONF_PICTURE = "picture" +CONF_TO_ATTRIBUTE = { + CONF_ICON: ATTR_ICON, + CONF_NAME: ATTR_FRIENDLY_NAME, + CONF_PICTURE: ATTR_ENTITY_PICTURE, +} + TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_ICON): cv.template, @@ -440,3 +455,145 @@ class TemplateSensor(TemplateEntity, SensorEntity): self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) + + +class TriggerBaseEntity(Entity): + """Template Base entity based on trigger data.""" + + domain: str + extra_template_keys: tuple | None = None + extra_template_keys_complex: tuple | None = None + _unique_id: str | None + + def __init__( + self, + hass: HomeAssistant, + config: dict, + ) -> None: + """Initialize the entity.""" + self.hass = hass + + self._set_unique_id(config.get(CONF_UNIQUE_ID)) + + self._config = config + + self._static_rendered = {} + self._to_render_simple = [] + self._to_render_complex: list[str] = [] + + for itm in ( + CONF_AVAILABILITY, + CONF_ICON, + CONF_NAME, + CONF_PICTURE, + ): + if itm not in config: + continue + + if config[itm].is_static: + self._static_rendered[itm] = config[itm].template + else: + self._to_render_simple.append(itm) + + if self.extra_template_keys is not None: + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) + + # We make a copy so our initial render is 'unknown' and not 'unavailable' + self._rendered = dict(self._static_rendered) + self._parse_result = {CONF_AVAILABILITY} + + @property + def name(self) -> str | None: + """Name of the entity.""" + return self._rendered.get(CONF_NAME) + + @property + def unique_id(self) -> str | None: + """Return unique ID of the entity.""" + return self._unique_id + + @property + def device_class(self): # type: ignore[no-untyped-def] + """Return device class of the entity.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def icon(self) -> str | None: + """Return icon.""" + return self._rendered.get(CONF_ICON) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + return self._rendered.get(CONF_PICTURE) + + @property + def available(self) -> bool: + """Return availability of the entity.""" + return ( + self._rendered is not self._static_rendered + and + # Check against False so `None` is ok + self._rendered.get(CONF_AVAILABILITY) is not False + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get(CONF_ATTRIBUTES) + + async def async_added_to_hass(self) -> None: + """Handle being added to Home Assistant.""" + template_attach(self.hass, self._config) + + def _set_unique_id(self, unique_id: str | None) -> None: + """Set unique id.""" + self._unique_id = unique_id + + def restore_attributes(self, last_state: State) -> None: + """Restore attributes.""" + for conf_key, attr in CONF_TO_ATTRIBUTE.items(): + if conf_key not in self._config or attr not in last_state.attributes: + continue + self._rendered[conf_key] = last_state.attributes[attr] + + if CONF_ATTRIBUTES in self._config: + extra_state_attributes = {} + for attr in self._config[CONF_ATTRIBUTES]: + if attr not in last_state.attributes: + continue + extra_state_attributes[attr] = last_state.attributes[attr] + self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + try: + rendered = dict(self._static_rendered) + + for key in self._to_render_simple: + rendered[key] = self._config[key].async_render( + variables, + parse_result=key in self._parse_result, + ) + + for key in self._to_render_complex: + rendered[key] = render_complex( + self._config[key], + variables, + ) + + if CONF_ATTRIBUTES in self._config: + rendered[CONF_ATTRIBUTES] = render_complex( + self._config[CONF_ATTRIBUTES], + variables, + ) + + self._rendered = rendered + except TemplateError as err: + logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( + "Error rendering %s template for %s: %s", key, self.entity_id, err + ) + self._rendered = self._static_rendered diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index e2963b15ab4..40e1860b409 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -95,7 +95,7 @@ class TriggerInfo(TypedDict): trigger_data: TriggerData -@dataclass +@dataclass(slots=True) class PluggableActionsEntry: """Holder to keep track of all plugs and actions for a given trigger.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index e9de580bad1..c563ef09a52 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -71,6 +71,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self.name = name self.update_method = update_method self.update_interval = update_interval + self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() # It's None before the first successful update. @@ -113,6 +114,9 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self._debounced_refresh = request_refresh_debouncer + if self.config_entry: + self.config_entry.async_on_unload(self.async_shutdown) + @callback def async_add_listener( self, update_callback: CALLBACK_TYPE, context: Any = None @@ -141,13 +145,16 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): for update_callback, _ in list(self._listeners.values()): update_callback() + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + self._shutdown_requested = True + self._async_unsub_refresh() + await self._debounced_refresh.async_shutdown() + @callback def _unschedule_refresh(self) -> None: """Unschedule any pending refresh since there is no longer any listeners.""" - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None - + self._async_unsub_refresh() self._debounced_refresh.async_cancel() def async_contexts(self) -> Generator[Any, None, None]: @@ -156,6 +163,12 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): context for _, context in self._listeners.values() if context is not None ) + def _async_unsub_refresh(self) -> None: + """Cancel any scheduled call.""" + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" @@ -167,9 +180,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): # We do not cancel the debouncer here. If the refresh interval is shorter # than the debouncer cooldown, this would cause the debounce to never be called - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None + self._async_unsub_refresh() # We _floor_ utcnow to create a schedule on a rounded second, # minimizing the time between the point and the real activation. @@ -233,13 +244,10 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): raise_on_entry_error: bool = False, ) -> None: """Refresh data.""" - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None - + self._async_unsub_refresh() self._debounced_refresh.async_cancel() - if scheduled and self.hass.is_stopping: + if self._shutdown_requested or scheduled and self.hass.is_stopping: return if log_timing := self.logger.isEnabledFor(logging.DEBUG): @@ -352,10 +360,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): @callback def async_set_updated_data(self, data: _T) -> None: """Manually update data, notify listeners and reset refresh interval.""" - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None - + self._async_unsub_refresh() self._debounced_refresh.async_cancel() self.data = data diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 600fef5a134..963ddcf48fc 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -15,13 +15,14 @@ import logging import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, AwesomeVersionException, AwesomeVersionStrategy, ) +import voluptuous as vol from . import generated from .generated.application_credentials import APPLICATION_CREDENTIALS @@ -35,7 +36,10 @@ from .util.json import JSON_DECODE_EXCEPTIONS, json_loads # Typing imports that create a circular dependency if TYPE_CHECKING: + from .config_entries import ConfigEntry from .core import HomeAssistant + from .helpers import device_registry as dr + from .helpers.typing import ConfigType _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -119,7 +123,7 @@ class USBMatcher(USBMatcherRequired, USBMatcherOptional): """Matcher for the bluetooth integration.""" -@dataclass +@dataclass(slots=True) class HomeKitDiscoveredIntegration: """HomeKit model.""" @@ -260,6 +264,52 @@ async def async_get_config_flows( return flows +class ComponentProtocol(Protocol): + """Define the format of an integration.""" + + CONFIG_SCHEMA: vol.Schema + DOMAIN: str + + async def async_setup_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up a config entry.""" + + async def async_unload_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload a config entry.""" + + async def async_migrate_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Migrate an old config entry.""" + + async def async_remove_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Remove a config entry.""" + + async def async_remove_config_entry_device( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, + ) -> bool: + """Remove a config entry device.""" + + async def async_reset_platform( + self, hass: HomeAssistant, integration_name: str + ) -> None: + """Release resources.""" + + async def async_setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + def setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + async def async_get_integration_descriptions( hass: HomeAssistant, ) -> dict[str, Any]: @@ -750,14 +800,18 @@ class Integration: return self._all_dependencies_resolved - def get_component(self) -> ModuleType: + def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( + DATA_COMPONENTS, {} + ) if self.domain in cache: return cache[self.domain] try: - cache[self.domain] = importlib.import_module(self.pkg_path) + cache[self.domain] = cast( + ComponentProtocol, importlib.import_module(self.pkg_path) + ) except ImportError: raise except Exception as err: @@ -922,7 +976,7 @@ class CircularDependency(LoaderError): def _load_file( hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ModuleType | None: +) -> ComponentProtocol | None: """Try to load specified file. Looks in config dir first, then built-in components. @@ -957,7 +1011,7 @@ def _load_file( cache[comp_or_platform] = module - return module + return cast(ComponentProtocol, module) except ImportError as err: # This error happens if for example custom_components/switch @@ -981,7 +1035,7 @@ def _load_file( class ModuleWrapper: """Class to wrap a Python module and auto fill in hass argument.""" - def __init__(self, hass: HomeAssistant, module: ModuleType) -> None: + def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: """Initialize the module wrapper.""" self._hass = hass self._module = module @@ -1010,7 +1064,7 @@ class Components: integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) if isinstance(integration, Integration): - component: ModuleType | None = integration.get_component() + component: ComponentProtocol | None = integration.get_component() else: # Fallback to importing old-school component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 63564979ae3..4d2419d30c3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,30 +12,31 @@ attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.0.2 -bleak==0.20.1 +bleak==0.20.2 bluetooth-adapters==0.15.3 -bluetooth-auto-recovery==1.0.3 -bluetooth-data-tools==0.3.1 +bluetooth-auto-recovery==1.1.1 +bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==40.0.1 -dbus-fast==1.84.2 -fnvhash==0.1.0 +cryptography==40.0.2 +dbus-fast==1.85.0 +fnv-hash-fast==0.3.1 ha-av==10.0.0 -hass-nabucasa==0.63.1 +hass-nabucasa==0.66.2 hassil==1.0.6 -home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230411.1 -home-assistant-intents==2023.3.29 -httpx==0.23.3 +home-assistant-bluetooth==1.10.0 +home-assistant-frontend==20230503.1 +home-assistant-intents==2023.4.26 +httpx==0.24.0 ifaddr==0.1.7 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.8.7 +mutagen==1.46.0 +orjson==3.8.10 paho-mqtt==1.6.1 -pillow==9.4.0 -pip>=21.0,<23.1 +pillow==9.5.0 +pip>=21.0,<23.2 psutil-home-assistant==0.0.1 pyOpenSSL==23.1.0 pyserial==3.5 @@ -44,13 +45,14 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.7 +sqlalchemy==2.0.12 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.6.3 +ulid-transform==0.7.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -yarl==1.8.1 -zeroconf==0.56.0 +webrtcvad==2.0.10 +yarl==1.9.2 +zeroconf==0.58.2 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -100,7 +102,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.6.2 h11==0.14.0 -httpcore==0.16.3 +httpcore==0.17.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -154,6 +156,10 @@ pyOpenSSL>=23.1.0 # 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 + # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 @@ -162,3 +168,14 @@ faust-cchardet>=2.1.18 # which break wheel builds so we need at least 11.0.1 # https://github.com/aaugustin/websockets/issues/1329 websockets>=11.0.1 + +# pyasn1 0.5.0 has breaking changes which cause pysnmplib to fail +# until they are resolved, we need to pin pyasn1 to 0.4.8 and +# pysnmplib to 5.0.21 to avoid the issue. +# https://github.com/pyasn1/pyasn1/pull/30#issuecomment-1517564335 +# https://github.com/pysnmp/pysnmp/issues/51 +pyasn1==0.4.8 +pysnmplib==5.0.21 +# pysnmp is no longer maintained and does not work with newer +# python +pysnmp==1000000000.0.0 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index e5a87a4b092..9a86bed7594 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -34,7 +34,7 @@ ALPINE_RELEASE_FILE = "/etc/alpine-release" _LOGGER = logging.getLogger(__name__) -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class RuntimeConfig: """Class to hold the information for running Home Assistant.""" diff --git a/homeassistant/setup.py b/homeassistant/setup.py index ce502116cf2..36b17690d7e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -67,7 +67,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - hass.data[DATA_SETUP_DONE] = {domain: asyncio.Event() for domain in domains} + hass.data.setdefault(DATA_SETUP_DONE, {}) + hass.data[DATA_SETUP_DONE].update({domain: asyncio.Event() for domain in domains}) def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: @@ -236,7 +237,7 @@ async def _async_setup_component( SLOW_SETUP_WARNING, ) - task = None + task: Awaitable[bool] | None = None result: Any | bool = True try: if hasattr(component, "async_setup"): diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py new file mode 100644 index 00000000000..8324293e136 --- /dev/null +++ b/homeassistant/util/language.py @@ -0,0 +1,172 @@ +"""Helper methods for language selection in Home Assistant.""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import math +import operator +import re + +from homeassistant.const import MATCH_ALL + +SEPARATOR_RE = re.compile(r"[-_]") + + +def preferred_regions( + language: str, + country: str | None = None, + code: str | None = None, +) -> Iterable[str]: + """Yield an ordered list of regions for a language based on country/code hints. + + Regions should be checked for support in the returned order if no other + information is available. + """ + if country is not None: + yield country.upper() + + if language == "en": + # Prefer U.S. English if no country + if country is None: + yield "US" + elif language == "zh": + if code == "Hant": + yield "HK" + yield "TW" + else: + yield "CN" + + # fr -> fr-FR + yield language.upper() + + +def is_region(language: str, region: str | None) -> bool: + """Return true if region is not known to be a script/code instead.""" + if language == "es": + return region != "419" + + if language == "sr": + return region != "Latn" + + if language == "zh": + return region not in ("Hans", "Hant") + + return True + + +@dataclass +class Dialect: + """Language with optional region and script/code.""" + + language: str + region: str | None + code: str | None = None + + def __post_init__(self) -> None: + """Fix casing of language/region.""" + # Languages are lower-cased + self.language = self.language.casefold() + + if self.region is not None: + # Regions are upper-cased + self.region = self.region.upper() + + def score(self, dialect: Dialect, country: str | None = None) -> float: + """Return score for match with another dialect where higher is better. + + Score < 0 indicates a failure to match. + """ + if self.language != dialect.language: + # Not a match + return -1 + + if (self.region is None) and (dialect.region is None): + # Weak match with no region constraint + return 1 + + if (self.region is not None) and (dialect.region is not None): + if self.region == dialect.region: + # Exact language + region match + return math.inf + + # Regions are both set, but don't match + return 0 + + # Generate ordered list of preferred regions + pref_regions = list( + preferred_regions( + self.language, + country=country, + code=self.code, + ) + ) + + try: + # Determine score based on position in the preferred regions list. + if self.region is not None: + region_idx = pref_regions.index(self.region) + elif dialect.region is not None: + region_idx = pref_regions.index(dialect.region) + else: + # Can't happen, but mypy is not smart enough + raise ValueError() + + # More preferred regions are at the front. + # Add 1 to boost above a weak match where no regions are set. + return 1 + (len(pref_regions) - region_idx) + except ValueError: + # Region was not in preferred list + pass + + # Not a preferred region + return 0 + + @staticmethod + def parse(tag: str) -> Dialect: + """Parse language tag into language/region/code.""" + parts = SEPARATOR_RE.split(tag, maxsplit=1) + language = parts[0] + region: str | None = None + code: str | None = None + + if len(parts) > 1: + region_or_code = parts[1] + if is_region(language, region_or_code): + # US, GB, etc. + region = region_or_code + else: + # Hant, 419, etc. + code = region_or_code + + return Dialect( + language=language, + region=region, + code=code, + ) + + +def matches( + target: str, supported: Iterable[str], country: str | None = None +) -> list[str]: + """Return a sorted list of matching language tags based on a target tag and country hint.""" + if target == MATCH_ALL: + return list(supported) + + target_dialect = Dialect.parse(target) + + # Higher score is better + scored = sorted( + ( + ( + dialect := Dialect.parse(tag), + target_dialect.score(dialect, country=country), + tag, + ) + for tag in supported + ), + key=operator.itemgetter(1), + reverse=True, + ) + + # Score < 0 is not a match + return [tag for _dialect, score, tag in scored if score >= 0] diff --git a/homeassistant/components/trace/utils.py b/homeassistant/util/limited_size_dict.py similarity index 100% rename from homeassistant/components/trace/utils.py rename to homeassistant/util/limited_size_dict.py diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 5b8830e6571..aa1b933e0ae 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -1,12 +1,70 @@ """Helper to create SSL contexts.""" import contextlib +from functools import cache from os import environ import ssl import certifi +from homeassistant.backports.enum import StrEnum -def create_no_verify_ssl_context() -> ssl.SSLContext: + +class SSLCipherList(StrEnum): + """SSL cipher lists.""" + + PYTHON_DEFAULT = "python_default" + INTERMEDIATE = "intermediate" + MODERN = "modern" + + +SSL_CIPHER_LISTS = { + SSLCipherList.INTERMEDIATE: ( + "ECDHE-ECDSA-CHACHA20-POLY1305:" + "ECDHE-RSA-CHACHA20-POLY1305:" + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "DHE-RSA-AES128-GCM-SHA256:" + "DHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-AES128-SHA256:" + "ECDHE-RSA-AES128-SHA256:" + "ECDHE-ECDSA-AES128-SHA:" + "ECDHE-RSA-AES256-SHA384:" + "ECDHE-RSA-AES128-SHA:" + "ECDHE-ECDSA-AES256-SHA384:" + "ECDHE-ECDSA-AES256-SHA:" + "ECDHE-RSA-AES256-SHA:" + "DHE-RSA-AES128-SHA256:" + "DHE-RSA-AES128-SHA:" + "DHE-RSA-AES256-SHA256:" + "DHE-RSA-AES256-SHA:" + "ECDHE-ECDSA-DES-CBC3-SHA:" + "ECDHE-RSA-DES-CBC3-SHA:" + "EDH-RSA-DES-CBC3-SHA:" + "AES128-GCM-SHA256:" + "AES256-GCM-SHA384:" + "AES128-SHA256:" + "AES256-SHA256:" + "AES128-SHA:" + "AES256-SHA:" + "DES-CBC3-SHA:" + "!DSS" + ), + SSLCipherList.MODERN: ( + "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" + "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" + "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" + ), +} + + +@cache +def create_no_verify_ssl_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: """Return an SSL context that does not verify the server certificate. This is a copy of aiohttp's create_default_context() function, with the @@ -23,10 +81,16 @@ def create_no_verify_ssl_context() -> ssl.SSLContext: # This only works for OpenSSL >= 1.0.0 sslcontext.options |= ssl.OP_NO_COMPRESSION sslcontext.set_default_verify_paths() + if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT: + sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list]) + return sslcontext -def client_context() -> ssl.SSLContext: +@cache +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: """Return an SSL context for making requests.""" # Reuse environment variable definition from requests, since it's already a @@ -34,7 +98,13 @@ def client_context() -> ssl.SSLContext: # certs from certifi package. cafile = environ.get("REQUESTS_CA_BUNDLE", certifi.where()) - return ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile) + sslcontext = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile + ) + if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT: + sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list]) + + return sslcontext # Create this only once and reuse it @@ -71,13 +141,7 @@ def server_context_modern() -> ssl.SSLContext: if hasattr(ssl, "OP_NO_COMPRESSION"): context.options |= ssl.OP_NO_COMPRESSION - context.set_ciphers( - "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" - "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" - "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" - "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" - "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" - ) + context.set_ciphers(SSL_CIPHER_LISTS[SSLCipherList.MODERN]) return context @@ -97,38 +161,6 @@ def server_context_intermediate() -> ssl.SSLContext: if hasattr(ssl, "OP_NO_COMPRESSION"): context.options |= ssl.OP_NO_COMPRESSION - context.set_ciphers( - "ECDHE-ECDSA-CHACHA20-POLY1305:" - "ECDHE-RSA-CHACHA20-POLY1305:" - "ECDHE-ECDSA-AES128-GCM-SHA256:" - "ECDHE-RSA-AES128-GCM-SHA256:" - "ECDHE-ECDSA-AES256-GCM-SHA384:" - "ECDHE-RSA-AES256-GCM-SHA384:" - "DHE-RSA-AES128-GCM-SHA256:" - "DHE-RSA-AES256-GCM-SHA384:" - "ECDHE-ECDSA-AES128-SHA256:" - "ECDHE-RSA-AES128-SHA256:" - "ECDHE-ECDSA-AES128-SHA:" - "ECDHE-RSA-AES256-SHA384:" - "ECDHE-RSA-AES128-SHA:" - "ECDHE-ECDSA-AES256-SHA384:" - "ECDHE-ECDSA-AES256-SHA:" - "ECDHE-RSA-AES256-SHA:" - "DHE-RSA-AES128-SHA256:" - "DHE-RSA-AES128-SHA:" - "DHE-RSA-AES256-SHA256:" - "DHE-RSA-AES256-SHA:" - "ECDHE-ECDSA-DES-CBC3-SHA:" - "ECDHE-RSA-DES-CBC3-SHA:" - "EDH-RSA-DES-CBC3-SHA:" - "AES128-GCM-SHA256:" - "AES256-GCM-SHA384:" - "AES128-SHA256:" - "AES256-SHA256:" - "AES128-SHA:" - "AES256-SHA:" - "DES-CBC3-SHA:" - "!DSS" - ) + context.set_ciphers(SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE]) return context diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 2a7af577769..c9da324e8a5 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -277,6 +277,12 @@ METRIC_SYSTEM = UnitSystem( ("water", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert wind speeds except knots to km/h + **{ + ("wind_speed", unit): UnitOfSpeed.KILOMETERS_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) + }, }, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, @@ -341,6 +347,12 @@ US_CUSTOMARY_SYSTEM = UnitSystem( # Convert non-USCS volumes of water meters ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + # Convert wind speeds except knots to mph + **{ + ("wind_speed", unit): UnitOfSpeed.MILES_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) + }, }, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index e7b262ad496..b2320a74d2c 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -18,7 +18,7 @@ class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class Input: """Input that should be substituted.""" diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index 5aeaca50780..2109262b342 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -5,6 +5,5 @@ RUN apk --no-cache add \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 6eed9e94142..25182586707 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -7,7 +7,6 @@ RUN apk --no-cache add \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 1647f91813c..48838cc13e1 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -7,7 +7,6 @@ RUN apk --no-cache add \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 6eed9e94142..25182586707 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -7,7 +7,6 @@ RUN apk --no-cache add \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 1647f91813c..48838cc13e1 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -7,7 +7,6 @@ RUN apk --no-cache add \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/tinker b/machine/tinker index 5976d533188..2fb01f2b545 100644 --- a/machine/tinker +++ b/machine/tinker @@ -4,6 +4,5 @@ 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 \ - pygatt[GATTTOOL] \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/mypy.ini b/mypy.ini index b3a4cafba36..2fe9310907f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -331,6 +331,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.anova.*] +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.anthemav.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -371,6 +381,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.assist_pipeline.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.asuswrt.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1132,6 +1152,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homeassistant.exposed_entities] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homeassistant.triggers.event] 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 a84d578cf52..a3cdb139131 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -135,8 +135,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "mock_zeroconf": "None", "mqtt_client_mock": "MqttMockPahoClient", "mqtt_mock": "MqttMockHAClient", - "mqtt_mock_entry_no_yaml_config": "MqttMockHAClientGenerator", - "mqtt_mock_entry_with_yaml_config": "MqttMockHAClientGenerator", + "mqtt_mock_entry": "MqttMockHAClientGenerator", "recorder_db_url": "str", "recorder_mock": "Recorder", "requests_mock": "requests_mock.Mocker", @@ -202,6 +201,14 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", ), + TypeHintMatch( + function_name="async_reset_platform", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=None, + ), ], "__any_platform__": [ TypeHintMatch( diff --git a/pyproject.toml b/pyproject.toml index 26b90151610..ae07e6d7672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.6" +version = "2023.5.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -34,26 +34,26 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.23.3", - "home-assistant-bluetooth==1.9.3", + "httpx==0.24.0", + "home-assistant-bluetooth==1.10.0", "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==40.0.1", + "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.7", - "pip>=21.0,<23.1", + "orjson==3.8.10", + "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.6.3", + "ulid-transform==0.7.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.8.1", + "yarl==1.9.2", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index fe6ffb649bb..425e82d4311 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,21 +10,21 @@ awesomeversion==22.9.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.23.3 -home-assistant-bluetooth==1.9.3 +httpx==0.24.0 +home-assistant-bluetooth==1.10.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.6.0 -cryptography==40.0.1 +cryptography==40.0.2 pyOpenSSL==23.1.0 -orjson==3.8.7 -pip>=21.0,<23.1 +orjson==3.8.10 +pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.6.3 +ulid-transform==0.7.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.8.1 +yarl==1.9.2 diff --git a/requirements_all.txt b/requirements_all.txt index 8d23f37514b..4b431dc87e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -50,7 +50,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -71,7 +71,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.0 +accuweather==0.5.1 # homeassistant.components.adax adax==0.2.0 @@ -86,7 +86,7 @@ adext==0.4.2 adguardhome==0.6.1 # homeassistant.components.advantage_air -advantage_air==0.4.1 +advantage_air==0.4.4 # homeassistant.components.frontier_silicon afsapi==0.2.7 @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.6.1 +aioesphomeapi==13.7.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -223,7 +223,7 @@ aionanoleaf==0.2.1 aionotify==0.2.0 # homeassistant.components.notion -aionotion==3.0.2 +aionotion==2023.04.2 # homeassistant.components.oncue aiooncue==0.3.4 @@ -258,7 +258,7 @@ aiorecollect==1.0.8 aioridwell==2023.01.0 # homeassistant.components.ruuvi_gateway -aioruuvigateway==0.0.2 +aioruuvigateway==0.1.0 # homeassistant.components.senseme aiosenseme==0.6.1 @@ -282,7 +282,7 @@ aiosomecomfort==0.0.14 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.2.1 +aioswitcher==3.3.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==46 +aiounifi==47 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -332,9 +332,15 @@ amcrest==1.9.7 # homeassistant.components.androidtv androidtv[async]==0.0.70 +# homeassistant.components.androidtv_remote +androidtvremote2==0.0.7 + # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 +# homeassistant.components.anova +anova-wifi==0.8.0 + # homeassistant.components.anthemav anthemav==1.4.1 @@ -354,7 +360,7 @@ aqualogic==2.6 aranet4==2.1.3 # homeassistant.components.arcam_fmj -arcam-fmj==1.2.1 +arcam-fmj==1.3.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==1.2.1 @@ -377,10 +383,10 @@ async-upnp-client==0.33.1 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.2.3 +asyncsleepiq==1.3.4 # homeassistant.components.aten_pe -atenpdu==0.3.2 +# atenpdu==0.3.2 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -401,7 +407,7 @@ axis==47 azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus -azure-servicebus==7.8.0 +# azure-servicebus==7.8.0 # homeassistant.components.baidu baidu-aip==1.6.6 @@ -413,7 +419,7 @@ base36==0.1.1 batinfo==0.4.2 # homeassistant.components.eddystone_temperature -# beacontools[scan]==1.2.3 +# beacontools[scan]==2.1.0 # homeassistant.components.scrape beautifulsoup4==4.11.1 @@ -422,10 +428,10 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.1 +bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -434,7 +440,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.1 +bleak==0.20.2 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -459,12 +465,13 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.0.3 +bluetooth-auto-recovery==1.1.1 # homeassistant.components.bluetooth +# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==0.3.1 +bluetooth-data-tools==0.4.0 # homeassistant.components.bond bond-async==0.1.23 @@ -541,9 +548,6 @@ connect-box==0.2.8 # homeassistant.components.xiaomi_miio construct==2.10.56 -# homeassistant.components.coronavirus -coronavirus==1.1.1 - # homeassistant.components.utility_meter croniter==1.0.6 @@ -563,10 +567,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.2 +dbus-fast==1.85.0 # homeassistant.components.debugpy -debugpy==1.6.6 +debugpy==1.6.7 # homeassistant.components.decora # decora==0.6 @@ -613,7 +617,7 @@ dovado==0.4.1 dsmr_parser==0.33 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.5 +dwdwfsapi==1.0.6 # homeassistant.components.dweet dweepy==0.3.0 @@ -625,7 +629,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.3 +easyenergy==0.3.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -643,7 +647,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.1 # homeassistant.components.elmax -elmax_api==0.0.2 +elmax_api==0.0.4 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -661,7 +665,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.33 +env_canada==0.5.34 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 @@ -729,13 +733,13 @@ flux_led==0.28.37 # homeassistant.components.homekit # homeassistant.components.recorder -fnvhash==0.1.0 +fnv-hash-fast==0.3.1 # homeassistant.components.foobot foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.2.0 +forecast_solar==3.0.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -798,7 +802,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.30 +goodwe==0.2.31 # homeassistant.components.google_mail google-api-python-client==2.71.0 @@ -868,7 +872,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.63.1 +hass-nabucasa==0.66.2 # homeassistant.components.splunk hass_splunk==0.1.1 @@ -907,16 +911,16 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230411.1 +home-assistant-frontend==20230503.1 # homeassistant.components.conversation -home-assistant-intents==2023.3.29 +home-assistant-intents==2023.4.26 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.13 +homematicip==1.0.14 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -979,7 +983,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.4 +insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1030,7 +1034,7 @@ krakenex==2.1.0 lacrosse-view==0.0.9 # homeassistant.components.eufy -lakeside==0.12 +lakeside==0.13 # homeassistant.components.laundrify laundrify_aio==1.1.2 @@ -1069,7 +1073,7 @@ limitlessled==1.1.3 linode-api==4.1.9b1 # homeassistant.components.google_maps -locationsharinglib==4.1.5 +locationsharinglib==5.0.1 # homeassistant.components.logi_circle logi_circle==0.2.3 @@ -1195,7 +1199,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.3.0 +nextdns==1.4.0 # homeassistant.components.nibe_heatpump nibe==2.1.4 @@ -1260,7 +1264,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.11 +onvif-zeep-async==1.3.1 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1290,7 +1294,7 @@ opensensemap-api==0.2.0 openwebifpy==3.2.7 # homeassistant.components.luci -openwrt-luci-rpc==1.1.11 +openwrt-luci-rpc==1.1.16 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 @@ -1355,7 +1359,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==9.4.0 +pillow==9.5.0 # homeassistant.components.dominos pizzapi==0.0.3 @@ -1370,7 +1374,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.5 +plugwise==0.31.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1400,10 +1404,11 @@ prometheus_client==0.7.1 proxmoxer==2.0.1 # homeassistant.components.hardware +# homeassistant.components.recorder psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.4 +psutil==5.9.5 # homeassistant.components.pulseaudio_loopback pulsectl==20.2.4 @@ -1470,7 +1475,7 @@ pyRFXtrx==0.30.1 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.27.0 +pyTibber==0.27.1 # homeassistant.components.dlink pyW215==0.7.0 @@ -1528,7 +1533,7 @@ pyblackbird==0.6 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.2 +pybravia==0.3.3 # homeassistant.components.nissan_leaf pycarwings2==2.14 @@ -1570,7 +1575,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==110 +pydeconz==111 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1588,7 +1593,7 @@ pydroid-ipcam==2.0.0 pyebox==1.1.4 # homeassistant.components.econet -pyeconet==0.1.18 +pyeconet==0.1.20 # homeassistant.components.edimax pyedimax==0.2.1 @@ -1618,7 +1623,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.6.9 +pyfibaro==0.7.0 # homeassistant.components.fido pyfido==2.1.2 @@ -1738,7 +1743,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lastfm -pylast==4.2.1 +pylast==5.1.0 # homeassistant.components.launch_library pylaunches==1.3.0 @@ -1807,7 +1812,7 @@ pynetgear==0.10.9 pynetio==0.1.9.1 # homeassistant.components.nina -pynina==0.2.0 +pynina==0.3.0 # homeassistant.components.nobo_hub pynobo==1.6.0 @@ -1881,7 +1886,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.8 +pyprosegur==0.0.9 # homeassistant.components.prusalink pyprusalink==1.1.0 @@ -1952,7 +1957,7 @@ pysesame2==1.0.1 pysher==1.0.7 # homeassistant.components.sia -pysiaalarm==3.0.2 +pysiaalarm==3.1.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.18 @@ -2102,6 +2107,9 @@ python-qbittorrent==0.4.2 # homeassistant.components.ripple python-ripple-api==0.0.3 +# homeassistant.components.roborock +python-roborock==0.8.3 + # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2124,7 +2132,7 @@ python_awair==0.2.4 python_opendata_transport==0.3.0 # homeassistant.components.egardia -pythonegardia==1.0.40 +pythonegardia==1.0.52 # homeassistant.components.tile pytile==2023.04.0 @@ -2150,7 +2158,7 @@ pytrafikverket==0.2.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.1 +pyunifiprotect==4.8.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2168,7 +2176,7 @@ pyversasense==0.0.6 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.60 +pyvizio==0.1.61 # homeassistant.components.velux pyvlx==0.2.20 @@ -2221,6 +2229,9 @@ radiotherm==2.1.0 # homeassistant.components.raincloud raincloudy==0.0.7 +# homeassistant.components.rapt_ble +rapt-ble==0.1.0 + # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 @@ -2231,7 +2242,7 @@ regenmaschine==2022.11.0 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.10 +reolink-aio==0.5.13 # homeassistant.components.python_script restrictedpython==6.0 @@ -2303,14 +2314,14 @@ screenlogicpy==0.8.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.11.1 +sense_energy==0.11.2 # homeassistant.components.sensirion_ble sensirion-ble==0.0.1 @@ -2322,7 +2333,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.20.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -2367,7 +2378,7 @@ snapcast==2.3.2 soco==0.29.1 # homeassistant.components.solaredge_local -solaredge-local==0.2.0 +solaredge-local==0.2.3 # homeassistant.components.solaredge solaredge==0.0.2 @@ -2378,6 +2389,9 @@ solax==0.3.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 +# homeassistant.components.sonos +sonos-websocket==0.1.0 + # homeassistant.components.marytts speak2mary==1.4.0 @@ -2388,11 +2402,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.22.1 +spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.7 +sqlalchemy==2.0.12 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2518,10 +2532,10 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.4 +tplink-omada-client==1.2.4 # homeassistant.components.transmission -transmission-rpc==3.4.0 +transmission-rpc==4.1.5 # homeassistant.components.twinkly ttls==1.5.1 @@ -2579,6 +2593,9 @@ venstarcolortouch==0.19 # homeassistant.components.vilfo vilfo-api-client==0.3.2 +# homeassistant.components.voip +voip-utils==0.0.7 + # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -2616,8 +2633,11 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.assist_pipeline +webrtcvad==2.0.10 + # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.2 +whirlpool-sixth-sense==0.18.3 # homeassistant.components.whois whois==0.9.27 @@ -2637,17 +2657,17 @@ wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 +# homeassistant.components.wyoming +wyoming==0.0.1 + # homeassistant.components.xbox xbox-webapi==2.0.11 -# homeassistant.components.xbox_live -xboxapi==2.0.1 - # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.4 +xiaomi-ble==0.17.0 # homeassistant.components.knx -xknx==2.7.0 +xknx==2.9.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2665,10 +2685,10 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.14 +yalexs-ble==2.1.16 # homeassistant.components.august -yalexs==1.2.7 +yalexs==1.3.3 # homeassistant.components.yeelight yeelight==0.7.10 @@ -2692,13 +2712,13 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.56.0 +zeroconf==0.58.2 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.97 +zha-quirks==0.0.99 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2707,25 +2727,25 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.20.0 +zigpy-deconz==0.21.0 # homeassistant.components.zha -zigpy-xbee==0.17.0 +zigpy-xbee==0.18.0 # homeassistant.components.zha -zigpy-zigate==0.10.3 +zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.10.0 +zigpy-znp==0.11.1 # homeassistant.components.zha -zigpy==0.54.1 +zigpy==0.55.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.3 +zwave-js-server-python==0.48.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.6 +zwave_me_ws==0.4.2 diff --git a/requirements_test.txt b/requirements_test.txt index e593f460454..82e861fc9e9 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.0 -coverage==7.2.1 +astroid==2.15.3 +coverage==7.2.3 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.1.1 +mypy==1.2.0 pre-commit==3.1.0 pydantic==1.10.7 -pylint==2.17.0 +pylint==2.17.2 pylint-per-file-ignores==1.1.0 pipdeptree==2.7.0 pytest-asyncio==0.20.3 @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.2.1 -pytest==7.2.2 +pytest==7.3.1 requests_mock==1.10.0 respx==0.20.1 syrupy==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04292652fc5..222be3a03ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -46,7 +46,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -61,7 +61,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.0 +accuweather==0.5.1 # homeassistant.components.adax adax==0.2.0 @@ -76,7 +76,7 @@ adext==0.4.2 adguardhome==0.6.1 # homeassistant.components.advantage_air -advantage_air==0.4.1 +advantage_air==0.4.4 # homeassistant.components.frontier_silicon afsapi==0.2.7 @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.6.1 +aioesphomeapi==13.7.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -204,7 +204,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==3.0.2 +aionotion==2023.04.2 # homeassistant.components.oncue aiooncue==0.3.4 @@ -239,7 +239,7 @@ aiorecollect==1.0.8 aioridwell==2023.01.0 # homeassistant.components.ruuvi_gateway -aioruuvigateway==0.0.2 +aioruuvigateway==0.1.0 # homeassistant.components.senseme aiosenseme==0.6.1 @@ -263,7 +263,7 @@ aiosomecomfort==0.0.14 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.2.1 +aioswitcher==3.3.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==46 +aiounifi==47 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -307,6 +307,12 @@ ambiclimate==0.2.1 # homeassistant.components.androidtv androidtv[async]==0.0.70 +# homeassistant.components.androidtv_remote +androidtvremote2==0.0.7 + +# homeassistant.components.anova +anova-wifi==0.8.0 + # homeassistant.components.anthemav anthemav==1.4.1 @@ -323,7 +329,7 @@ aprslib==0.7.0 aranet4==2.1.3 # homeassistant.components.arcam_fmj -arcam-fmj==1.2.1 +arcam-fmj==1.3.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -334,7 +340,7 @@ arcam-fmj==1.2.1 async-upnp-client==0.33.1 # homeassistant.components.sleepiq -asyncsleepiq==1.2.3 +asyncsleepiq==1.3.4 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -355,16 +361,16 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.35.1 +bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.1 +bleak==0.20.2 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -379,12 +385,13 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.0.3 +bluetooth-auto-recovery==1.1.1 # homeassistant.components.bluetooth +# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==0.3.1 +bluetooth-data-tools==0.4.0 # homeassistant.components.bond bond-async==0.1.23 @@ -398,6 +405,9 @@ broadlink==0.18.3 # homeassistant.components.brother brother==2.3.0 +# homeassistant.components.brottsplatskartan +brottsplatskartan==0.0.1 + # homeassistant.components.brunt brunt==1.2.0 @@ -427,9 +437,6 @@ colorthief==0.2.1 # homeassistant.components.xiaomi_miio construct==2.10.56 -# homeassistant.components.coronavirus -coronavirus==1.1.1 - # homeassistant.components.utility_meter croniter==1.0.6 @@ -449,10 +456,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.2 +dbus-fast==1.85.0 # homeassistant.components.debugpy -debugpy==1.6.6 +debugpy==1.6.7 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -493,7 +500,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.3 +easyenergy==0.3.0 # homeassistant.components.elgato elgato==4.0.1 @@ -502,7 +509,7 @@ elgato==4.0.1 elkm1-lib==2.2.1 # homeassistant.components.elmax -elmax_api==0.0.2 +elmax_api==0.0.4 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -517,7 +524,7 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.33 +env_canada==0.5.34 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 @@ -557,13 +564,13 @@ flux_led==0.28.37 # homeassistant.components.homekit # homeassistant.components.recorder -fnvhash==0.1.0 +fnv-hash-fast==0.3.1 # homeassistant.components.foobot foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.2.0 +forecast_solar==3.0.0 # homeassistant.components.freebox freebox-api==1.1.0 @@ -614,7 +621,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.30 +goodwe==0.2.31 # homeassistant.components.google_mail google-api-python-client==2.71.0 @@ -666,7 +673,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.63.1 +hass-nabucasa==0.66.2 # homeassistant.components.conversation hassil==1.0.6 @@ -693,16 +700,16 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230411.1 +home-assistant-frontend==20230503.1 # homeassistant.components.conversation -home-assistant-intents==2023.3.29 +home-assistant-intents==2023.4.26 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.13 +homematicip==1.0.14 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -741,7 +748,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.4 +insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -894,7 +901,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.3.0 +nextdns==1.4.0 # homeassistant.components.nibe_heatpump nibe==2.1.4 @@ -938,7 +945,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.11 +onvif-zeep-async==1.3.1 # homeassistant.components.opengarage open-garage==0.2.0 @@ -997,7 +1004,7 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==9.4.0 +pillow==9.5.0 # homeassistant.components.plex plexapi==4.13.2 @@ -1009,7 +1016,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.5 +plugwise==0.31.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1030,6 +1037,7 @@ progettihwsw==0.1.1 prometheus_client==0.7.1 # homeassistant.components.hardware +# homeassistant.components.recorder psutil-home-assistant==0.0.1 # homeassistant.components.androidtv @@ -1082,7 +1090,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.27.0 +pyTibber==0.27.1 # homeassistant.components.dlink pyW215==0.7.0 @@ -1122,7 +1130,7 @@ pyblackbird==0.6 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.2 +pybravia==0.3.3 # homeassistant.components.cloudflare pycfdns==2.0.1 @@ -1140,7 +1148,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==110 +pydeconz==111 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1149,7 +1157,7 @@ pydexcom==0.2.3 pydroid-ipcam==2.0.0 # homeassistant.components.econet -pyeconet==0.1.18 +pyeconet==0.1.20 # homeassistant.components.efergy pyefergy==22.1.1 @@ -1167,7 +1175,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.6.9 +pyfibaro==0.7.0 # homeassistant.components.fido pyfido==2.1.2 @@ -1257,7 +1265,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lastfm -pylast==4.2.1 +pylast==5.1.0 # homeassistant.components.launch_library pylaunches==1.3.0 @@ -1308,7 +1316,7 @@ pymysensors==0.24.0 pynetgear==0.10.9 # homeassistant.components.nina -pynina==0.2.0 +pynina==0.3.0 # homeassistant.components.nobo_hub pynobo==1.6.0 @@ -1373,7 +1381,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.8 +pyprosegur==0.0.9 # homeassistant.components.prusalink pyprusalink==1.1.0 @@ -1420,7 +1428,7 @@ pyserial-asyncio==0.6 pyserial==3.5 # homeassistant.components.sia -pysiaalarm==3.0.2 +pysiaalarm==3.1.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.18 @@ -1504,6 +1512,12 @@ python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 +# homeassistant.components.qbittorrent +python-qbittorrent==0.4.2 + +# homeassistant.components.roborock +python-roborock==0.8.3 + # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1540,7 +1554,7 @@ pytrafikverket==0.2.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.1 +pyunifiprotect==4.8.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1552,7 +1566,7 @@ pyvera==0.3.13 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.60 +pyvizio==0.1.61 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -1587,6 +1601,9 @@ radios==0.1.1 # homeassistant.components.radiotherm radiotherm==2.1.0 +# homeassistant.components.rapt_ble +rapt-ble==0.1.0 + # homeassistant.components.rainmachine regenmaschine==2022.11.0 @@ -1594,7 +1611,7 @@ regenmaschine==2022.11.0 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.10 +reolink-aio==0.5.13 # homeassistant.components.python_script restrictedpython==6.0 @@ -1639,11 +1656,11 @@ scapy==2.5.0 screenlogicpy==0.8.2 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.11.1 +sense_energy==0.11.2 # homeassistant.components.sensirion_ble sensirion-ble==0.0.1 @@ -1655,7 +1672,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.20.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -1681,6 +1698,9 @@ smart-meter-texas==0.4.7 # homeassistant.components.smhi smhi-pkg==1.0.16 +# homeassistant.components.snapcast +snapcast==2.3.2 + # homeassistant.components.sonos soco==0.29.1 @@ -1693,6 +1713,9 @@ solax==0.3.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 +# homeassistant.components.sonos +sonos-websocket==0.1.0 + # homeassistant.components.marytts speak2mary==1.4.0 @@ -1703,11 +1726,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.22.1 +spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.7 +sqlalchemy==2.0.12 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1788,10 +1811,10 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.4 +tplink-omada-client==1.2.4 # homeassistant.components.transmission -transmission-rpc==3.4.0 +transmission-rpc==4.1.5 # homeassistant.components.twinkly ttls==1.5.1 @@ -1846,6 +1869,9 @@ venstarcolortouch==0.19 # homeassistant.components.vilfo vilfo-api-client==0.3.2 +# homeassistant.components.voip +voip-utils==0.0.7 + # homeassistant.components.volvooncall volvooncall==0.10.2 @@ -1868,8 +1894,11 @@ wallbox==0.4.12 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.components.assist_pipeline +webrtcvad==2.0.10 + # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.2 +whirlpool-sixth-sense==0.18.3 # homeassistant.components.whois whois==0.9.27 @@ -1886,14 +1915,17 @@ wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 +# homeassistant.components.wyoming +wyoming==0.0.1 + # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.4 +xiaomi-ble==0.17.0 # homeassistant.components.knx -xknx==2.7.0 +xknx==2.9.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1908,10 +1940,10 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.14 +yalexs-ble==2.1.16 # homeassistant.components.august -yalexs==1.2.7 +yalexs==1.3.3 # homeassistant.components.yeelight yeelight==0.7.10 @@ -1926,31 +1958,31 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.56.0 +zeroconf==0.58.2 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.97 +zha-quirks==0.0.99 # homeassistant.components.zha -zigpy-deconz==0.20.0 +zigpy-deconz==0.21.0 # homeassistant.components.zha -zigpy-xbee==0.17.0 +zigpy-xbee==0.18.0 # homeassistant.components.zha -zigpy-zigate==0.10.3 +zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.10.0 +zigpy-znp==0.11.1 # homeassistant.components.zha -zigpy==0.54.1 +zigpy==0.55.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.3 +zwave-js-server-python==0.48.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.6 +zwave_me_ws==0.4.2 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a1faadfea4a..9954e97da97 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,8 +1,8 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==23.1.0 +black==23.3.0 codespell==2.2.2 isort==5.12.0 -ruff==0.0.256 +ruff==0.0.262 yamllint==1.28.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 585acc944dc..f3479d47789 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -21,8 +21,10 @@ else: COMMENT_REQUIREMENTS = ( "Adafruit_BBIO", + "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", @@ -105,7 +107,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.6.2 h11==0.14.0 -httpcore==0.16.3 +httpcore==0.17.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -159,6 +161,10 @@ pyOpenSSL>=23.1.0 # 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 + # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 @@ -167,6 +173,17 @@ faust-cchardet>=2.1.18 # which break wheel builds so we need at least 11.0.1 # https://github.com/aaugustin/websockets/issues/1329 websockets>=11.0.1 + +# pyasn1 0.5.0 has breaking changes which cause pysnmplib to fail +# until they are resolved, we need to pin pyasn1 to 0.4.8 and +# pysnmplib to 5.0.21 to avoid the issue. +# https://github.com/pyasn1/pyasn1/pull/30#issuecomment-1517564335 +# https://github.com/pysnmp/pysnmp/issues/51 +pyasn1==0.4.8 +pysnmplib==5.0.21 +# pysnmp is no longer maintained and does not work with newer +# python +pysnmp==1000000000.0.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8d2f179aef4..c0733841ed5 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast from collections import deque +import multiprocessing from pathlib import Path from homeassistant.const import Platform @@ -227,35 +228,49 @@ def find_non_referenced_integrations( return referenced -def validate_dependencies( - integrations: dict[str, Integration], +def _compute_integration_dependencies( integration: Integration, - check_dependencies: bool, -) -> None: - """Validate all dependencies.""" +) -> tuple[str, dict[Path, set[str]] | None]: + """Compute integration dependencies.""" # Some integrations are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: - return + return (integration.domain, None) # Find usage of hass.components collector = ImportCollector(integration) collector.collect() + return (integration.domain, collector.referenced) - for domain in sorted( - find_non_referenced_integrations( - integrations, integration, collector.referenced - ) - ): - integration.add_error( - "dependencies", - f"Using component {domain} but it's not in 'dependencies' " - "or 'after_dependencies'", + +def _validate_dependency_imports( + integrations: dict[str, Integration], +) -> None: + """Validate all dependencies.""" + + # Find integration dependencies with multiprocessing + # (because it takes some time to parse thousands of files) + with multiprocessing.Pool() as pool: + integration_imports = dict( + pool.imap_unordered( + _compute_integration_dependencies, + integrations.values(), + chunksize=10, + ) ) - if check_dependencies: - _check_circular_deps( - integrations, integration.domain, integration, set(), deque() - ) + for integration in integrations.values(): + referenced = integration_imports[integration.domain] + if not referenced: # Either ignored or has no references + continue + + for domain in sorted( + find_non_referenced_integrations(integrations, integration, referenced) + ): + integration.add_error( + "dependencies", + f"Using component {domain} but it's not in 'dependencies' " + "or 'after_dependencies'", + ) def _check_circular_deps( @@ -266,6 +281,7 @@ def _check_circular_deps( checking: deque[str], ) -> None: """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: return @@ -297,20 +313,24 @@ def _check_circular_deps( checking.remove(integration.domain) -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Handle dependencies for integrations.""" - # check for non-existing dependencies +def _validate_circular_dependencies(integrations: dict[str, Integration]) -> None: for integration in integrations.values(): - validate_dependencies( - integrations, - integration, - check_dependencies=not config.specific_integrations, - ) - - if config.specific_integrations: + if integration.domain in IGNORE_VIOLATIONS: + continue + + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() + ) + + +def _validate_dependencies( + integrations: dict[str, Integration], +) -> None: + """Check that all referenced dependencies exist and are not duplicated.""" + for integration in integrations.values(): + if not integration.manifest: continue - # check that all referenced dependencies exist after_deps = integration.manifest.get("after_dependencies", []) for dep in integration.manifest.get("dependencies", []): if dep in after_deps: @@ -323,3 +343,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: integration.add_error( "dependencies", f"Dependency {dep} does not exist" ) + + +def validate( + integrations: dict[str, Integration], + config: Config, +) -> None: + """Handle dependencies for integrations.""" + _validate_dependency_imports(integrations) + + if not config.specific_integrations: + _validate_dependencies(integrations) + _validate_circular_dependencies(integrations) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index bb969313967..a0c629567fa 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -34,10 +34,10 @@ FIELD_SCHEMA = vol.Schema( vol.Optional("advanced"): bool, vol.Optional(CONF_SELECTOR): selector.validate_selector, vol.Optional("filter"): { - vol.Optional("attribute"): { + vol.Exclusive("attribute", "field_filter"): { vol.Required(str): [vol.All(str, service.validate_attribute_option)], }, - vol.Optional("supported_features"): [ + vol.Exclusive("supported_features", "field_filter"): [ vol.All(str, service.validate_supported_feature) ], }, diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index bf2697644da..9efe01cf962 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -22,6 +22,7 @@ REMOVED = 2 RE_REFERENCE = r"\[\%key:(.+)\%\]" RE_TRANSLATION_KEY = re.compile(r"^(?!.+[_-]{2})(?![_-])[a-z0-9-_]+(? str: return value +def translation_value_validator(value: Any) -> str: + """Validate that the value is a valid translation. + + - prevents string with HTML + - prevents combined translations + """ + value = cv.string_with_no_html(value) + if RE_COMBINED_REFERENCE.search(value): + raise vol.Invalid("the string should not contain combined translations") + return str(value) + + def gen_data_entry_schema( *, config: Config, @@ -127,24 +140,24 @@ def gen_data_entry_schema( """Generate a data entry schema.""" step_title_class = vol.Required if require_step_title else vol.Optional schema = { - vol.Optional("flow_title"): cv.string_with_no_html, + vol.Optional("flow_title"): translation_value_validator, vol.Required("step"): { str: { - step_title_class("title"): cv.string_with_no_html, - vol.Optional("description"): cv.string_with_no_html, - vol.Optional("data"): {str: cv.string_with_no_html}, - vol.Optional("data_description"): {str: cv.string_with_no_html}, - vol.Optional("menu_options"): {str: cv.string_with_no_html}, - vol.Optional("submit"): cv.string_with_no_html, + step_title_class("title"): translation_value_validator, + vol.Optional("description"): translation_value_validator, + vol.Optional("data"): {str: translation_value_validator}, + vol.Optional("data_description"): {str: translation_value_validator}, + vol.Optional("menu_options"): {str: translation_value_validator}, + vol.Optional("submit"): translation_value_validator, } }, - vol.Optional("error"): {str: cv.string_with_no_html}, - vol.Optional("abort"): {str: cv.string_with_no_html}, - vol.Optional("progress"): {str: cv.string_with_no_html}, - vol.Optional("create_entry"): {str: cv.string_with_no_html}, + vol.Optional("error"): {str: translation_value_validator}, + vol.Optional("abort"): {str: translation_value_validator}, + vol.Optional("progress"): {str: translation_value_validator}, + vol.Optional("create_entry"): {str: translation_value_validator}, } if flow_title == REQUIRED: - schema[vol.Required("title")] = cv.string_with_no_html + schema[vol.Required("title")] = translation_value_validator elif flow_title == REMOVED: schema[vol.Optional("title", msg=REMOVED_TITLE_MSG)] = partial( removed_title_validator, config, integration @@ -201,7 +214,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: """Generate a strings schema.""" return vol.Schema( { - vol.Optional("title"): cv.string_with_no_html, + vol.Optional("title"): translation_value_validator, vol.Optional("config"): gen_data_entry_schema( config=config, integration=integration, @@ -220,40 +233,43 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("selector"): cv.schema_with_slug_keys( { "options": cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=translation_key_validator + translation_value_validator, + slug_validator=translation_key_validator, ) }, slug_validator=vol.Any("_", cv.slug), ), vol.Optional("device_automation"): { - vol.Optional("action_type"): {str: cv.string_with_no_html}, - vol.Optional("condition_type"): {str: cv.string_with_no_html}, - vol.Optional("trigger_type"): {str: cv.string_with_no_html}, - vol.Optional("trigger_subtype"): {str: cv.string_with_no_html}, + vol.Optional("action_type"): {str: translation_value_validator}, + vol.Optional("condition_type"): {str: translation_value_validator}, + vol.Optional("trigger_type"): {str: translation_value_validator}, + vol.Optional("trigger_subtype"): {str: translation_value_validator}, }, vol.Optional("system_health"): { vol.Optional("info"): cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=translation_key_validator + translation_value_validator, + slug_validator=translation_key_validator, ), }, vol.Optional("config_panel"): cv.schema_with_slug_keys( cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=translation_key_validator + translation_value_validator, + slug_validator=translation_key_validator, ), slug_validator=vol.Any("_", cv.slug), ), vol.Optional("application_credentials"): { - vol.Optional("description"): cv.string_with_no_html, + vol.Optional("description"): translation_value_validator, }, vol.Optional("issues"): { str: vol.All( cv.has_at_least_one_key("description", "fix_flow"), vol.Schema( { - vol.Required("title"): cv.string_with_no_html, + vol.Required("title"): translation_value_validator, vol.Exclusive( "description", "fixable" - ): cv.string_with_no_html, + ): translation_value_validator, vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( config=config, integration=integration, @@ -268,14 +284,14 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: { vol.Optional("name"): str, vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + translation_value_validator, slug_validator=translation_key_validator, ), vol.Optional("state_attributes"): cv.schema_with_slug_keys( { vol.Optional("name"): str, vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + translation_value_validator, slug_validator=translation_key_validator, ), }, @@ -287,16 +303,16 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("entity"): cv.schema_with_slug_keys( cv.schema_with_slug_keys( { - vol.Optional("name"): cv.string_with_no_html, + vol.Optional("name"): translation_value_validator, vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + translation_value_validator, slug_validator=translation_key_validator, ), vol.Optional("state_attributes"): cv.schema_with_slug_keys( { - vol.Optional("name"): cv.string_with_no_html, + vol.Optional("name"): translation_value_validator, vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + translation_value_validator, slug_validator=translation_key_validator, ), }, @@ -386,7 +402,9 @@ def gen_platform_strings_schema(config: Config, integration: Integration) -> vol ) -ONBOARDING_SCHEMA = vol.Schema({vol.Required("area"): {str: cv.string_with_no_html}}) +ONBOARDING_SCHEMA = vol.Schema( + {vol.Required("area"): {str: translation_value_validator}} +) def validate_translation_file( # noqa: C901 @@ -415,7 +433,7 @@ def validate_translation_file( # noqa: C901 strings_schema = gen_strings_schema(config, integration).extend( { vol.Optional("device_class"): cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=vol.Any("_", cv.slug) + translation_value_validator, slug_validator=vol.Any("_", cv.slug) ) } ) diff --git a/script/translations/develop.py b/script/translations/develop.py index 067aa84444b..a318c7c08bc 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -69,7 +69,7 @@ def substitute_translation_references(integration_strings, flattened_translation def substitute_reference(value, flattened_translations): """Substitute localization key references in a translation string.""" - matches = re.findall(r"\[\%key:((?:[\w]+|[:]{2})*)\%\]", value) + matches = re.findall(r"\[\%key:((?:[a-z0-9-_]+|[:]{2})*)\%\]", value) if not matches: return value diff --git a/tests/common.py b/tests/common.py index f7a2c04a5f5..632294a50fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1268,7 +1268,7 @@ def mock_integration( def mock_import_platform(platform_name: str) -> NoReturn: raise ImportError( - f"Mocked unable to import platform '{platform_name}'", + f"Mocked unable to import platform '{integration.pkg_path}.{platform_name}'", name=f"{integration.pkg_path}.{platform_name}", ) diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json index 70b7bd1181b..1b432fbd075 100644 --- a/tests/components/accuweather/fixtures/forecast_data.json +++ b/tests/components/accuweather/fixtures/forecast_data.json @@ -15,34 +15,35 @@ "UnitType": 17 } }, - "Ozone": { - "Value": 32, - "Category": "Good", - "CategoryValue": 1 + "AirQuality": { + "Value": 0, + "Category": "good", + "CategoryValue": 1, + "Type": "Ozone" }, "Grass": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Mold": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Ragweed": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Tree": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "UVIndex": { "Value": 5, - "Category": "Moderate", + "Category": "moderate", "CategoryValue": 2 }, "TemperatureMin": { @@ -214,34 +215,35 @@ "UnitType": 17 } }, - "Ozone": { - "Value": 39, - "Category": "Good", - "CategoryValue": 1 + "AirQuality": { + "Value": 0, + "Category": "good", + "CategoryValue": 1, + "Type": "Ozone" }, "Grass": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Mold": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Ragweed": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Tree": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "UVIndex": { "Value": 7, - "Category": "High", + "Category": "high", "CategoryValue": 3 }, "TemperatureMin": { @@ -409,34 +411,35 @@ "UnitType": 17 } }, - "Ozone": { - "Value": 29, - "Category": "Good", - "CategoryValue": 1 + "AirQuality": { + "Value": 0, + "Category": "good", + "CategoryValue": 1, + "Type": "Ozone" }, "Grass": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Mold": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Ragweed": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Tree": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "UVIndex": { "Value": 7, - "Category": "High", + "Category": "high", "CategoryValue": 3 }, "TemperatureMin": { @@ -604,34 +607,35 @@ "UnitType": 17 } }, - "Ozone": { - "Value": 18, - "Category": "Good", - "CategoryValue": 1 + "AirQuality": { + "Value": 0, + "Category": "good", + "CategoryValue": 1, + "Type": "Ozone" }, "Grass": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Mold": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Ragweed": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Tree": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "UVIndex": { "Value": 6, - "Category": "High", + "Category": "high", "CategoryValue": 3 }, "TemperatureMin": { @@ -799,34 +803,35 @@ "UnitType": 17 } }, - "Ozone": { - "Value": 14, - "Category": "Good", - "CategoryValue": 1 + "AirQuality": { + "Value": 0, + "Category": "good", + "CategoryValue": 1, + "Type": "Ozone" }, "Grass": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Mold": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Ragweed": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "Tree": { "Value": 0, - "Category": "Low", + "Category": "low", "CategoryValue": 1 }, "UVIndex": { "Value": 7, - "Category": "High", + "Category": "high", "CategoryValue": 3 }, "TemperatureMin": { diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index a6a9f9c04fc..4d8d83a2a2f 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -26,7 +26,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_api_key_too_short(hass: HomeAssistant) -> None: diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 2b6f5132745..7123d5ef817 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -5,9 +5,11 @@ from unittest.mock import patch from accuweather import ApiError from homeassistant.components.accuweather.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from . import init_integration @@ -113,3 +115,21 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert mock_current.call_count == 1 assert mock_forecast.call_count == 1 + + +async def test_remove_ozone_sensors(hass: HomeAssistant) -> None: + """Test remove ozone sensors from registry.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + SENSOR_PLATFORM, + DOMAIN, + "0123456-ozone-0", + suggested_object_id="home_ozone_0d", + disabled_by=None, + ) + + await init_integration(hass) + + entry = registry.async_get("sensor.home_ozone_0d") + assert entry is None diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index e4f564f1335..37e58504efe 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -189,13 +189,32 @@ async def test_sensor_with_forecast(hass: HomeAssistant) -> None: 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("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.""" @@ -305,13 +324,6 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: suggested_object_id="home_mold_pollen_0d", disabled_by=None, ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-ozone-0", - suggested_object_id="home_ozone_0d", - disabled_by=None, - ) registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, @@ -506,7 +518,7 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER ) - assert state.attributes.get("level") == "Low" + assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:grass" assert state.attributes.get(ATTR_STATE_CLASS) is None @@ -522,25 +534,13 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER ) - assert state.attributes.get("level") == "Low" + assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get("sensor.home_mold_pollen_0d") assert entry assert entry.unique_id == "0123456-mold-0" - state = hass.states.get("sensor.home_ozone_0d") - assert state - assert state.state == "32" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get("level") == "Good" - assert state.attributes.get(ATTR_ICON) == "mdi:vector-triangle" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_ozone_0d") - assert entry - assert entry.unique_id == "0123456-ozone-0" - state = hass.states.get("sensor.home_ragweed_pollen_0d") assert state assert state.state == "0" @@ -549,7 +549,7 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER ) - assert state.attributes.get("level") == "Low" + assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:sprout" entry = registry.async_get("sensor.home_ragweed_pollen_0d") @@ -587,7 +587,7 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER ) - assert state.attributes.get("level") == "Low" + assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None @@ -741,11 +741,21 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "10500.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" + assert state.state == "10498.687664042" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET + state = hass.states.get("sensor.home_wind") + assert state + assert state.state == "9.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR + + state = hass.states.get("sensor.home_realfeel_temperature") + assert state + assert state.state == "77.2" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT + ) + async def test_state_update(hass: HomeAssistant) -> None: """Ensure the sensor state changes after updating the data.""" diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 66826f82d17..ab4faf73c81 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -14,7 +14,6 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, @@ -46,7 +45,6 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.state == "sunny" assert not state.attributes.get(ATTR_FORECAST) assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 67 - assert not state.attributes.get(ATTR_WEATHER_OZONE) assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1012.0 assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 @@ -68,7 +66,6 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state assert state.state == "sunny" assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 67 - assert state.attributes.get(ATTR_WEATHER_OZONE) == 32 assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1012.0 assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 54f1d2d86af..f62172ebfad 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -134,6 +134,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: }, name="AdGuard Home Addon", slug="adguard", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -158,6 +159,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: }, name="AdGuard Home Addon", slug="adguard", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -186,6 +188,7 @@ async def test_hassio_confirm( }, name="AdGuard Home Addon", slug="adguard", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -228,6 +231,7 @@ async def test_hassio_connection_error( }, name="AdGuard Home Addon", slug="adguard", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/advantage_air/__init__.py b/tests/components/advantage_air/__init__.py index e415485821f..b826e3ac7ce 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -18,7 +18,10 @@ TEST_SYSTEM_URL = ( ) TEST_SET_URL = f"http://{USER_INPUT[CONF_IP_ADDRESS]}:{USER_INPUT[CONF_PORT]}/setAircon" TEST_SET_LIGHT_URL = ( - f"http://{USER_INPUT[CONF_IP_ADDRESS]}:{USER_INPUT[CONF_PORT]}/setLight" + f"http://{USER_INPUT[CONF_IP_ADDRESS]}:{USER_INPUT[CONF_PORT]}/setLights" +) +TEST_SET_THING_URL = ( + f"http://{USER_INPUT[CONF_IP_ADDRESS]}:{USER_INPUT[CONF_PORT]}/setThings" ) diff --git a/tests/components/advantage_air/fixtures/getSystemData.json b/tests/components/advantage_air/fixtures/getSystemData.json index 327ee2d53d5..3548a45554f 100644 --- a/tests/components/advantage_air/fixtures/getSystemData.json +++ b/tests/components/advantage_air/fixtures/getSystemData.json @@ -2,6 +2,8 @@ "aircons": { "ac1": { "info": { + "aaAutoFanModeEnabled": false, + "climateControlModeEnabled": false, "climateControlModeIsRunning": false, "countDownToOff": 10, "countDownToOn": 0, @@ -9,8 +11,10 @@ "filterCleanStatus": 0, "freshAirStatus": "off", "mode": "vent", + "myAutoModeEnabled": false, + "myAutoModeIsRunning": false, "myZone": 1, - "name": "AC One", + "name": "myzone", "setTemp": 24, "state": "on" }, @@ -94,20 +98,76 @@ }, "ac2": { "info": { + "aaAutoFanModeEnabled": true, + "climateControlModeEnabled": true, "climateControlModeIsRunning": false, "countDownToOff": 0, "countDownToOn": 20, - "fan": "low", + "fan": "autoAA", + "filterCleanStatus": 1, + "freshAirStatus": "none", + "mode": "cool", + "myAutoModeCurrentSetMode": "cool", + "myAutoModeEnabled": false, + "myAutoModeIsRunning": false, + "myZone": 1, + "name": "mytemp", + "setTemp": 24, + "state": "off" + }, + "zones": { + "z01": { + "error": 0, + "maxDamper": 100, + "measuredTemp": 25, + "minDamper": 0, + "motion": 20, + "motionConfig": 2, + "name": "Zone A", + "number": 1, + "rssi": 40, + "setTemp": 24, + "state": "open", + "type": 1, + "value": 100 + }, + "z02": { + "error": 0, + "maxDamper": 100, + "measuredTemp": 26, + "minDamper": 0, + "motion": 21, + "motionConfig": 2, + "name": "Zone B", + "number": 2, + "rssi": 10, + "setTemp": 23, + "state": "open", + "type": 1, + "value": 50 + } + } + }, + "ac3": { + "info": { + "aaAutoFanModeEnabled": true, + "climateControlModeEnabled": false, + "climateControlModeIsRunning": false, + "countDownToOff": 0, + "countDownToOn": 0, + "fan": "autoAA", "filterCleanStatus": 1, "freshAirStatus": "none", "mode": "myauto", "myAutoModeCurrentSetMode": "cool", "myAutoModeEnabled": true, "myAutoModeIsRunning": true, + "myAutoCoolTargetTemp": 24, + "myAutoHeatTargetTemp": 20, "myZone": 0, - "name": "AC Two", + "name": "myauto", "setTemp": 24, - "state": "off" + "state": "on" }, "zones": { "z01": { @@ -117,7 +177,7 @@ "minDamper": 0, "motion": 0, "motionConfig": 0, - "name": "Zone open without sensor", + "name": "Zone Y", "number": 1, "rssi": 0, "setTemp": 24, @@ -132,7 +192,7 @@ "minDamper": 0, "motion": 0, "motionConfig": 0, - "name": "Zone closed without sensor", + "name": "Zone Z", "number": 2, "rssi": 0, "setTemp": 24, @@ -160,11 +220,64 @@ } } }, + "myThings": { + "things": { + "200": { + "buttonType": "upDown", + "channelDipState": 1, + "id": "200", + "name": "Blind 1", + "value": 100 + }, + "201": { + "buttonType": "upDown", + "channelDipState": 2, + "id": "201", + "name": "Blind 2", + "value": 0 + }, + "202": { + "buttonType": "openClose", + "channelDipState": 3, + "id": "202", + "name": "Garage", + "value": 100 + }, + "203": { + "buttonType": "onOff", + "channelDipState": 4, + "id": "203", + "name": "Thing Light", + "value": 100 + }, + "204": { + "buttonType": "upDown", + "channelDipState": 5, + "id": "204", + "name": "Thing Light Dimmable", + "value": 100 + }, + "205": { + "buttonType": "onOff", + "channelDipState": 8, + "id": "205", + "name": "Relay", + "value": 100 + }, + "206": { + "buttonType": "onOff", + "channelDipState": 9, + "id": "206", + "name": "Fan", + "value": 100 + } + } + }, "system": { "hasAircons": true, "hasLights": true, "hasSensors": false, - "hasThings": false, + "hasThings": true, "hasThingsBOG": false, "hasThingsLight": false, "needsUpdate": false, diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index ebccd4d0d78..37e816b366b 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -39,7 +39,7 @@ async def test_binary_sensor_async_setup_entry( assert len(aioclient_mock.mock_calls) == 1 # Test First Air Filter - entity_id = "binary_sensor.ac_one_filter" + entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -49,7 +49,7 @@ async def test_binary_sensor_async_setup_entry( assert entry.unique_id == "uniqueid-ac1-filter" # Test Second Air Filter - entity_id = "binary_sensor.ac_two_filter" + entity_id = "binary_sensor.mytemp_filter" state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -59,7 +59,7 @@ async def test_binary_sensor_async_setup_entry( assert entry.unique_id == "uniqueid-ac2-filter" # Test First Motion Sensor - entity_id = "binary_sensor.ac_one_zone_open_with_sensor_motion" + entity_id = "binary_sensor.myzone_zone_open_with_sensor_motion" state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -69,7 +69,7 @@ async def test_binary_sensor_async_setup_entry( assert entry.unique_id == "uniqueid-ac1-z01-motion" # Test Second Motion Sensor - entity_id = "binary_sensor.ac_one_zone_closed_with_sensor_motion" + entity_id = "binary_sensor.myzone_zone_closed_with_sensor_motion" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -79,7 +79,7 @@ async def test_binary_sensor_async_setup_entry( assert entry.unique_id == "uniqueid-ac1-z02-motion" # Test First MyZone Sensor (disabled by default) - entity_id = "binary_sensor.ac_one_zone_open_with_sensor_myzone" + entity_id = "binary_sensor.myzone_zone_open_with_sensor_myzone" assert not hass.states.get(entity_id) @@ -101,7 +101,7 @@ async def test_binary_sensor_async_setup_entry( assert entry.unique_id == "uniqueid-ac1-z01-myzone" # Test Second Motion Sensor (disabled by default) - entity_id = "binary_sensor.ac_one_zone_closed_with_sensor_myzone" + entity_id = "binary_sensor.myzone_zone_closed_with_sensor_myzone" assert not hass.states.get(entity_id) diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index b3412cb1bc2..b045092d78d 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -4,6 +4,8 @@ from json import loads import pytest from homeassistant.components.advantage_air.climate import ( + ADVANTAGE_AIR_COOL_TARGET, + ADVANTAGE_AIR_HEAT_TARGET, HASS_FAN_MODES, HASS_HVAC_MODES, ) @@ -14,8 +16,13 @@ from homeassistant.components.advantage_air.const import ( ADVANTAGE_AIR_STATE_OPEN, ) from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, FAN_LOW, SERVICE_SET_FAN_MODE, @@ -58,20 +65,21 @@ async def test_climate_async_setup_entry( registry = er.async_get(hass) - # Test Main Climate Entity - entity_id = "climate.ac_one" + # Test MyZone Climate Entity + entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state assert state.state == HVACMode.FAN_ONLY - assert state.attributes.get("min_temp") == 16 - assert state.attributes.get("max_temp") == 32 - assert state.attributes.get("temperature") == 24 - assert state.attributes.get("current_temperature") is None + assert state.attributes.get(ATTR_MIN_TEMP) == 16 + assert state.attributes.get(ATTR_MAX_TEMP) == 32 + assert state.attributes.get(ATTR_TEMPERATURE) == 24 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None entry = registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1" + # Test setting HVAC Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -86,6 +94,7 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Turning Off with HVAC Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -99,6 +108,7 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test changing Fan Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -112,6 +122,7 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test changing Temperature await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -125,6 +136,7 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Turning On await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_TURN_OFF, @@ -138,6 +150,7 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Turning Off await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_TURN_ON, @@ -152,24 +165,26 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" # Test Climate Zone Entity - entity_id = "climate.ac_one_zone_open_with_sensor" + entity_id = "climate.myzone_zone_open_with_sensor" state = hass.states.get(entity_id) assert state - assert state.attributes.get("min_temp") == 16 - assert state.attributes.get("max_temp") == 32 - assert state.attributes.get("temperature") == 24 - assert state.attributes.get("current_temperature") == 25 + assert state.attributes.get(ATTR_MIN_TEMP) == 16 + assert state.attributes.get(ATTR_MAX_TEMP) == 32 + assert state.attributes.get(ATTR_TEMPERATURE) == 24 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 entry = registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z01" + # Test Climate Zone On await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) + assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -178,12 +193,14 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Climate Zone Off await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) + assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -197,36 +214,38 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test MyAuto Climate Entity + entity_id = "climate.myauto" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac3" await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_LOW: 21, + ATTR_TARGET_TEMP_HIGH: 23, + }, blocking=True, ) assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 + assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 async def test_climate_async_failed_update( @@ -248,7 +267,7 @@ async def test_climate_async_failed_update( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: ["climate.ac_one"], ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) assert aioclient_mock.mock_calls[-1][0] == "GET" diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index 8999cbe6e68..80162b448d1 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -19,6 +19,7 @@ from homeassistant.helpers import entity_registry as er from . import ( TEST_SET_RESPONSE, + TEST_SET_THING_URL, TEST_SET_URL, TEST_SYSTEM_DATA, TEST_SYSTEM_URL, @@ -28,7 +29,7 @@ from . import ( from tests.test_util.aiohttp import AiohttpClientMocker -async def test_cover_async_setup_entry( +async def test_ac_cover( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test cover platform.""" @@ -46,10 +47,8 @@ async def test_cover_async_setup_entry( registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test Cover Zone Entity - entity_id = "cover.ac_two_zone_open_without_sensor" + entity_id = "cover.myauto_zone_y" state = hass.states.get(entity_id) assert state assert state.state == STATE_OPEN @@ -58,7 +57,7 @@ async def test_cover_async_setup_entry( entry = registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-ac2-z01" + assert entry.unique_id == "uniqueid-ac3-z01" await hass.services.async_call( COVER_DOMAIN, @@ -66,11 +65,10 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -80,12 +78,11 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac2"]["zones"]["z01"]["value"] == 100 + assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert data["ac3"]["zones"]["z01"]["value"] == 100 assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -95,11 +92,10 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 7 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["value"] == 50 + assert data["ac3"]["zones"]["z01"]["value"] == 50 assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -109,11 +105,10 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 9 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -123,28 +118,85 @@ async def test_cover_async_setup_entry( SERVICE_CLOSE_COVER, { ATTR_ENTITY_ID: [ - "cover.ac_two_zone_open_without_sensor", - "cover.ac_two_zone_closed_without_sensor", + "cover.myauto_zone_y", + "cover.myauto_zone_z", ] }, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 11 data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac2"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, { ATTR_ENTITY_ID: [ - "cover.ac_two_zone_open_without_sensor", - "cover.ac_two_zone_closed_without_sensor", + "cover.myauto_zone_y", + "cover.myauto_zone_z", ] }, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 13 data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac2"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + +async def test_things_cover( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test cover platform.""" + + aioclient_mock.get( + TEST_SYSTEM_URL, + text=TEST_SYSTEM_DATA, + ) + aioclient_mock.get( + TEST_SET_THING_URL, + text=TEST_SET_RESPONSE, + ) + + await add_mock_config(hass) + + registry = er.async_get(hass) + + # Test Blind 1 Entity + entity_id = "cover.blind_1" + thing_id = "200" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get("device_class") == CoverDeviceClass.BLIND + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-200" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) + assert data["id"] == thing_id + assert data["value"] == 0 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) + assert data["id"] == thing_id + assert data["value"] == 100 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 070008fc2f3..a1d38857116 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -11,13 +11,14 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import ( TEST_SET_LIGHT_URL, TEST_SET_RESPONSE, + TEST_SET_THING_URL, TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config, @@ -26,9 +27,7 @@ from . import ( from tests.test_util.aiohttp import AiohttpClientMocker -async def test_light_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_light(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test light setup.""" aioclient_mock.get( @@ -44,17 +43,16 @@ async def test_light_async_setup_entry( registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test Light Entity entity_id = "light.light_a" + light_id = "100" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF entry = registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-100" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -62,11 +60,10 @@ async def test_light_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLight" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["id"] == "100" + assert aioclient_mock.mock_calls[-2][1].path == "/setLights" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id assert data["state"] == ADVANTAGE_AIR_STATE_ON assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -77,21 +74,35 @@ async def test_light_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLight" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["id"] == "100" + assert aioclient_mock.mock_calls[-2][1].path == "/setLights" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id assert data["state"] == ADVANTAGE_AIR_STATE_OFF assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" # Test Dimmable Light Entity entity_id = "light.light_b" + light_id = "101" entry = registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-101" + assert entry.unique_id == f"uniqueid-{light_id}" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setLights" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id + assert data["state"] == ADVANTAGE_AIR_STATE_ON + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" await hass.services.async_call( LIGHT_DOMAIN, @@ -99,12 +110,69 @@ async def test_light_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 7 assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLight" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["id"] == "101" + assert aioclient_mock.mock_calls[-2][1].path == "/setLights" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id assert data["value"] == 50 assert data["state"] == ADVANTAGE_AIR_STATE_ON assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + +async def test_things_light( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test things lights.""" + + aioclient_mock.get( + TEST_SYSTEM_URL, + text=TEST_SYSTEM_DATA, + ) + aioclient_mock.get( + TEST_SET_THING_URL, + text=TEST_SET_RESPONSE, + ) + + await add_mock_config(hass) + + registry = er.async_get(hass) + + # Test Switch Entity + entity_id = "light.thing_light_dimmable" + light_id = "204" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-204" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id + assert data["value"] == 0 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) + assert data["id"] == light_id + assert data["value"] == 50 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 45c47e8bfe1..9209862f3c9 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -42,7 +42,7 @@ async def test_select_async_setup_entry( assert len(aioclient_mock.mock_calls) == 1 # Test MyZone Select Entity - entity_id = "select.ac_one_myzone" + entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) assert state assert state.state == "Zone open with Sensor" diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 9fc17fa55b2..2a7be320be6 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -45,7 +45,7 @@ async def test_sensor_platform( assert len(aioclient_mock.mock_calls) == 1 # Test First TimeToOn Sensor - entity_id = "sensor.ac_one_time_to_on" + entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) assert state assert int(state.state) == 0 @@ -70,7 +70,7 @@ async def test_sensor_platform( assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" # Test First TimeToOff Sensor - entity_id = "sensor.ac_one_time_to_off" + entity_id = "sensor.myzone_time_to_off" state = hass.states.get(entity_id) assert state assert int(state.state) == 10 @@ -95,7 +95,7 @@ async def test_sensor_platform( assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" # Test First Zone Vent Sensor - entity_id = "sensor.ac_one_zone_open_with_sensor_vent" + entity_id = "sensor.myzone_zone_open_with_sensor_vent" state = hass.states.get(entity_id) assert state assert int(state.state) == 100 @@ -105,7 +105,7 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-z01-vent" # Test Second Zone Vent Sensor - entity_id = "sensor.ac_one_zone_closed_with_sensor_vent" + entity_id = "sensor.myzone_zone_closed_with_sensor_vent" state = hass.states.get(entity_id) assert state assert int(state.state) == 0 @@ -115,7 +115,7 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-z02-vent" # Test First Zone Signal Sensor - entity_id = "sensor.ac_one_zone_open_with_sensor_signal" + entity_id = "sensor.myzone_zone_open_with_sensor_signal" state = hass.states.get(entity_id) assert state assert int(state.state) == 40 @@ -125,7 +125,7 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-z01-signal" # Test Second Zone Signal Sensor - entity_id = "sensor.ac_one_zone_closed_with_sensor_signal" + entity_id = "sensor.myzone_zone_closed_with_sensor_signal" state = hass.states.get(entity_id) assert state assert int(state.state) == 10 @@ -135,7 +135,7 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-z02-signal" # Test First Zone Temp Sensor (disabled by default) - entity_id = "sensor.ac_one_zone_open_with_sensor_temperature" + entity_id = "sensor.myzone_zone_open_with_sensor_temperature" assert not hass.states.get(entity_id) diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 9730262352a..36851037623 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -10,12 +10,13 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import ( TEST_SET_RESPONSE, + TEST_SET_THING_URL, TEST_SET_URL, TEST_SYSTEM_DATA, TEST_SYSTEM_URL, @@ -43,10 +44,8 @@ async def test_cover_async_setup_entry( registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test Switch Entity - entity_id = "switch.ac_one_fresh_air" + entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -61,7 +60,6 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -75,10 +73,67 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + +async def test_things_switch( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test things switches.""" + + aioclient_mock.get( + TEST_SYSTEM_URL, + text=TEST_SYSTEM_DATA, + ) + aioclient_mock.get( + TEST_SET_THING_URL, + text=TEST_SET_RESPONSE, + ) + + await add_mock_config(hass) + + registry = er.async_get(hass) + + # Test Switch Entity + entity_id = "switch.relay" + thing_id = "205" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-205" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) + assert data["id"] == thing_id + assert data["value"] == 0 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setThings" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) + assert data["id"] == thing_id + assert data["value"] == 100 + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 8ec16d313f7..59a6993903f 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -36,7 +36,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/air_quality/test_air_quality.py b/tests/components/air_quality/test_air_quality.py index c2c18a6ed09..faaebda0aae 100644 --- a/tests/components/air_quality/test_air_quality.py +++ b/tests/components/air_quality/test_air_quality.py @@ -1,4 +1,6 @@ """The tests for the Air Quality component.""" +import pytest + from homeassistant.components.air_quality import ATTR_N2O, ATTR_OZONE, ATTR_PM_10 from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -9,6 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_state(hass: HomeAssistant) -> None: """Test Air Quality state.""" config = {"air_quality": {"platform": "demo"}} diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index 8a9b8807a19..2abd9bd1204 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -29,7 +29,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_invalid_api_key( diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 432f9c2bfd1..f360beb8c51 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -25,7 +25,7 @@ async def test_async_setup_entry( """Test a successful setup entry.""" await init_integration(hass, aioclient_mock) - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == "4.37" diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index a2aeae808f4..4888176e175 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -63,7 +63,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert entry.unique_id == "123-456-humidity" assert entry.options["sensor"] == {"suggested_display_precision": 1} - state = hass.states.get("sensor.home_particulate_matter_1_mm") + state = hass.states.get("sensor.home_pm1") assert state assert state.state == "2.83" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -74,12 +74,12 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_particulate_matter_1_mm") + entry = registry.async_get("sensor.home_pm1") assert entry assert entry.unique_id == "123-456-pm1" assert entry.options["sensor"] == {"suggested_display_precision": 0} - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4.37" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -90,12 +90,12 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_particulate_matter_2_5_mm") + entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-456-pm25" assert entry.options["sensor"] == {"suggested_display_precision": 0} - state = hass.states.get("sensor.home_particulate_matter_10_mm") + state = hass.states.get("sensor.home_pm10") assert state assert state.state == "6.06" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -106,7 +106,7 @@ async def test_sensor(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.home_particulate_matter_10_mm") + entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-456-pm10" assert entry.options["sensor"] == {"suggested_display_precision": 0} diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 1501c9ba863..2d89d0b556e 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -54,7 +54,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( @@ -97,7 +97,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {CONF_ID: "invalid_system_id"} mock_hvac.return_value = HVAC_MOCK[API_SYSTEMS][0] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 601f59fd118..a1f77a9b49b 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -39,6 +39,7 @@ def events(hass: HomeAssistant) -> list[Event]: @pytest.fixture async def mock_camera(hass: HomeAssistant) -> None: """Initialize a demo camera platform.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component( hass, "camera", {camera.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index ed118bc8274..ad7d3be290d 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -48,14 +48,14 @@ INVALID_MAC = "ff:ff:ff:ff:ff:ff" HOST = "127.0.0.1" VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}] -# Android TV device with Python ADB implementation +# Android device with Python ADB implementation CONFIG_PYTHON_ADB = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, } -# Android TV device with ADB server +# Android device with ADB server CONFIG_ADB_SERVER = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, @@ -70,7 +70,7 @@ CONNECT_METHOD = ( class MockConfigDevice: - """Mock class to emulate Android TV device.""" + """Mock class to emulate Android device.""" def __init__(self, eth_mac=ETH_MAC, wifi_mac=None): """Initialize a fake device to test config flow.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 3ecbd5b05f4..59c7ce751ac 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -95,8 +95,8 @@ MSG_RECONNECT = { SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_STANDBY = "1" -# Android TV device with Python ADB implementation -CONFIG_ANDROIDTV_PYTHON_ADB = { +# Android device with Python ADB implementation +CONFIG_ANDROID_PYTHON_ADB = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -106,28 +106,28 @@ CONFIG_ANDROIDTV_PYTHON_ADB = { }, } -# Android TV device with Python ADB implementation imported from YAML -CONFIG_ANDROIDTV_PYTHON_ADB_YAML = { +# Android device with Python ADB implementation imported from YAML +CONFIG_ANDROID_PYTHON_ADB_YAML = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: "ADB yaml import", DOMAIN: { CONF_NAME: "ADB yaml import", - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], }, } -# Android TV device with Python ADB implementation with custom adbkey -CONFIG_ANDROIDTV_PYTHON_ADB_KEY = { +# Android device with Python ADB implementation with custom adbkey +CONFIG_ANDROID_PYTHON_ADB_KEY = { ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: CONFIG_ANDROIDTV_PYTHON_ADB[TEST_ENTITY_NAME], + TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], DOMAIN: { - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], CONF_ADBKEY: "user_provided_adbkey", }, } -# Android TV device with ADB server -CONFIG_ANDROIDTV_ADB_SERVER = { +# Android device with ADB server +CONFIG_ANDROID_ADB_SERVER = { ADB_PATCH_KEY: patchers.KEY_SERVER, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -163,7 +163,7 @@ CONFIG_FIRETV_ADB_SERVER = { }, } -CONFIG_ANDROIDTV_DEFAULT = CONFIG_ANDROIDTV_PYTHON_ADB +CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB @@ -213,10 +213,10 @@ def _setup(config): @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, - CONFIG_ANDROIDTV_PYTHON_ADB_YAML, + CONFIG_ANDROID_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB_YAML, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -275,9 +275,9 @@ async def test_reconnect( @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -313,7 +313,7 @@ async def test_adb_shell_returns_none( async def test_setup_with_adbkey(hass: HomeAssistant) -> None: """Test that setup succeeds when using an ADB key.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB_KEY) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_PYTHON_ADB_KEY) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -331,12 +331,12 @@ async def test_setup_with_adbkey(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_DEFAULT, + CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT, ], ) async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" + """Test that sources (i.e., apps) are handled correctly for Android and Fire TV devices.""" conf_apps = { "com.app.test1": "TEST 1", "com.app.test3": None, @@ -397,7 +397,7 @@ async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.parametrize( ("config", "expected_sources"), [ - (CONFIG_ANDROIDTV_DEFAULT, ["TEST 1"]), + (CONFIG_ANDROID_DEFAULT, ["TEST 1"]), (CONFIG_FIRETV_DEFAULT, ["TEST 1"]), ], ) @@ -503,7 +503,7 @@ async def test_select_source_androidtv( "com.app.test3": None, } await _test_select_source( - hass, CONFIG_ANDROIDTV_DEFAULT, conf_apps, source, expected_arg, method_patch + hass, CONFIG_ANDROID_DEFAULT, conf_apps, source, expected_arg, method_patch ) @@ -517,7 +517,7 @@ async def test_androidtv_select_source_overridden_app_name(hass: HomeAssistant) assert "com.youtube.test" not in ANDROIDTV_APPS await _test_select_source( hass, - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, conf_apps, "YouTube", "com.youtube.test", @@ -554,9 +554,9 @@ async def test_select_source_firetv( @pytest.mark.parametrize( ("config", "connect"), [ - (CONFIG_ANDROIDTV_DEFAULT, False), + (CONFIG_ANDROID_DEFAULT, False), (CONFIG_FIRETV_DEFAULT, False), - (CONFIG_ANDROIDTV_DEFAULT, True), + (CONFIG_ANDROID_DEFAULT, True), (CONFIG_FIRETV_DEFAULT, True), ], ) @@ -581,7 +581,7 @@ async def test_setup_fail( async def test_adb_command(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = "test response" @@ -610,7 +610,7 @@ async def test_adb_command(hass: HomeAssistant) -> None: async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = b"test response" @@ -639,7 +639,7 @@ async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: async def test_adb_command_key(hass: HomeAssistant) -> None: """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "HOME" response = None @@ -668,7 +668,7 @@ async def test_adb_command_key(hass: HomeAssistant) -> None: async def test_adb_command_get_properties(hass: HomeAssistant) -> None: """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} @@ -698,7 +698,7 @@ async def test_adb_command_get_properties(hass: HomeAssistant) -> None: async def test_learn_sendevent(hass: HomeAssistant) -> None: """Test the `androidtv.learn_sendevent` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" @@ -727,7 +727,7 @@ async def test_learn_sendevent(hass: HomeAssistant) -> None: async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -760,7 +760,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: async def test_download(hass: HomeAssistant) -> None: """Test the `androidtv.download` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -806,7 +806,7 @@ async def test_download(hass: HomeAssistant) -> None: async def test_upload(hass: HomeAssistant) -> None: """Test the `androidtv.upload` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -851,8 +851,8 @@ async def test_upload(hass: HomeAssistant) -> None: async def test_androidtv_volume_set(hass: HomeAssistant) -> None: - """Test setting the volume for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test setting the volume for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -881,7 +881,7 @@ async def test_get_image_http( This is based on `test_get_image_http` in tests/components/media_player/test_init.py. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -894,7 +894,7 @@ async def test_get_image_http( await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -923,7 +923,7 @@ async def test_get_image_http( async def test_get_image_disabled(hass: HomeAssistant) -> None: """Test that the screencap option can disable entity_picture.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, options={CONF_SCREENCAP: False} @@ -939,7 +939,7 @@ async def test_get_image_disabled(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -954,7 +954,7 @@ async def _test_service( additional_service_data=None, return_value=None, ): - """Test generic Android TV media player entity service.""" + """Test generic Android media player entity service.""" service_data = {ATTR_ENTITY_ID: entity_id} if additional_service_data: service_data.update(additional_service_data) @@ -977,8 +977,8 @@ async def _test_service( async def test_services_androidtv(hass: HomeAssistant) -> None: - """Test media player services for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test media player services for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1042,7 +1042,7 @@ async def test_services_firetv(hass: HomeAssistant) -> None: async def test_volume_mute(hass: HomeAssistant) -> None: """Test the volume mute service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1085,7 +1085,7 @@ async def test_volume_mute(hass: HomeAssistant) -> None: async def test_connection_closed_on_ha_stop(hass: HomeAssistant) -> None: """Test that the ADB socket connection is closed when HA stops.""" - patch_key, _, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, _, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1105,7 +1105,7 @@ async def test_exception(hass: HomeAssistant) -> None: HA will attempt to reconnect on the next update. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1135,7 +1135,7 @@ async def test_exception(hass: HomeAssistant) -> None: async def test_options_reload(hass: HomeAssistant) -> None: """Test changing an option that will cause integration reload.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( diff --git a/tests/components/androidtv_remote/__init__.py b/tests/components/androidtv_remote/__init__.py new file mode 100644 index 00000000000..41b9d292807 --- /dev/null +++ b/tests/components/androidtv_remote/__init__.py @@ -0,0 +1 @@ +"""Tests for the Android TV Remote integration.""" diff --git a/tests/components/androidtv_remote/conftest.py b/tests/components/androidtv_remote/conftest.py new file mode 100644 index 00000000000..ffe9d8b8dbe --- /dev/null +++ b/tests/components/androidtv_remote/conftest.py @@ -0,0 +1,57 @@ +"""Fixtures for the Android TV Remote integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.androidtv_remote.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.androidtv_remote.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_unload_entry() -> Generator[AsyncMock, None, None]: + """Mock unloading a config entry.""" + with patch( + "homeassistant.components.androidtv_remote.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture +def mock_api() -> Generator[None, MagicMock, None]: + """Return a mocked AndroidTVRemote.""" + with patch( + "homeassistant.components.androidtv_remote.helpers.AndroidTVRemote", + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + mock_api.async_connect = AsyncMock(return_value=None) + mock_api.device_info = { + "manufacturer": "My Android TV manufacturer", + "model": "My Android TV model", + } + yield mock_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Android TV", + domain=DOMAIN, + data={"host": "1.2.3.4", "name": "My Android TV", "mac": "1A:2B:3C:4D:5E:6F"}, + unique_id="1a:2b:3c:4d:5e:6f", + state=ConfigEntryState.NOT_LOADED, + ) diff --git a/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr b/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8282f1dedde --- /dev/null +++ b/tests/components/androidtv_remote/snapshots/test_diagnostics.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'api_device_info': dict({ + 'manufacturer': 'My Android TV manufacturer', + 'model': 'My Android TV model', + }), + 'config_entry_data': dict({ + 'host': '**REDACTED**', + 'mac': '**REDACTED**', + 'name': 'My Android TV', + }), + }) +# --- diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py new file mode 100644 index 00000000000..ea1f4abfc1d --- /dev/null +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -0,0 +1,835 @@ +"""Test the Android TV Remote config flow.""" +from unittest.mock import AsyncMock, MagicMock + +from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.androidtv_remote.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full user flow from start to finish without any exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == name + assert result["data"] == {"host": host, "name": name, "mac": mac} + assert result["context"]["source"] == "user" + assert result["context"]["unique_id"] == unique_id + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_get_name_and_mac raises CannotConnect. + + This is when the user entered an invalid IP address so we stay + in the user step allowing the user to enter a different host. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises InvalidAuth. + + This is when the user entered an invalid PIN. We stay in the pair step + allowing the user to enter a different PIN. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert result["errors"] == {"base": "invalid_auth"} + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 1 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_connection_closed( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises ConnectionClosed. + + This is when the user canceled pairing on the Android TV itself before calling async_finish_pairing. + We call async_start_pairing again which succeeds and we have a chance to enter a new PIN. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 2 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises ConnectionClosed and then async_start_pairing raises CannotConnect. + + This is when the user unplugs the Android TV before calling async_finish_pairing. + We call async_start_pairing again which fails with CannotConnect so we abort. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=[None, CannotConnect()]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=ConnectionClosed()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 1 + assert mock_api.async_start_pairing.call_count == 2 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_already_configured_host_changed_reloads_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the user flow if already configured and reload if host changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name if different is from discovery and should not change" + host_existing = "1.2.3.45" + assert host_existing != host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name_existing, + "mac": mac, + } + + +async def test_user_flow_already_configured_host_not_changed_no_reload_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the user flow if already configured and no reload if host not changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name if different is from discovery and should not change" + host_existing = host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert "host" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac)) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": host} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + mock_api.async_get_name_and_mac.assert_called() + mock_api.async_start_pairing.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name_existing, + "mac": mac, + } + + +async def test_zeroconf_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full zeroconf flow from start to finish without any exceptions.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + 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={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "zeroconf_confirm" + assert result["context"]["source"] == "zeroconf" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == name + assert result["data"] == { + "host": host, + "name": name, + "mac": mac, + } + assert result["context"]["source"] == "zeroconf" + assert result["context"]["unique_id"] == unique_id + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_start_pairing raises CannotConnect in the zeroconf flow. + + This is when the Android TV became network unreachable after discovery. + We abort and let discovery find it again later. + """ + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + + 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={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_flow_pairing_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_finish_pairing raises InvalidAuth in the zeroconf flow. + + This is when the user entered an invalid PIN. We stay in the pair step + allowing the user to enter a different PIN. + """ + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + pin = "123456" + + 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={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert not result["data_schema"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth()) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert result["errors"] == {"base": "invalid_auth"} + + mock_api.async_finish_pairing.assert_called_with(pin) + + assert mock_api.async_get_name_and_mac.call_count == 0 + assert mock_api.async_start_pairing.call_count == 1 + assert mock_api.async_finish_pairing.call_count == 1 + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the zeroconf flow if already configured and reload if host or name changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = "existing name should change since we prefer one from discovery" + host_existing = "1.2.3.45" + assert host_existing != host + assert name_existing != name + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + 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={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test we abort the zeroconf flow if already configured and no reload if host and name not changed.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + name_existing = name + host_existing = host + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host_existing, + "name": name_existing, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + 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={"bt": mac}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reauth flow from start to finish without any exceptions.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + pin = "123456" + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host, + "name": name, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pair" + assert "pin" in result["data_schema"].schema + assert not result["errors"] + + mock_api.async_get_name_and_mac.assert_not_called() + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + mock_api.async_finish_pairing = AsyncMock(return_value=None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": pin} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + mock_api.async_finish_pairing.assert_called_with(pin) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "host": host, + "name": name, + "mac": mac, + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test async_start_pairing raises CannotConnect in the reauth flow.""" + host = "1.2.3.4" + name = "My Android TV" + mac = "1A:2B:3C:4D:5E:6F" + unique_id = "1a:2b:3c:4d:5e:6f" + + mock_config_entry = MockConfigEntry( + title=name, + domain=DOMAIN, + data={ + "host": host, + "name": name, + "mac": mac, + }, + unique_id=unique_id, + state=ConfigEntryState.LOADED, + ) + mock_config_entry.add_to_hass(hass) + + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["unique_id"] == unique_id + assert result["context"]["title_placeholders"] == {"name": name} + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect()) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.async_get_name_and_mac.assert_not_called() + mock_api.async_generate_cert_if_missing.assert_called() + mock_api.async_start_pairing.assert_called() + + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/androidtv_remote/test_diagnostics.py b/tests/components/androidtv_remote/test_diagnostics.py new file mode 100644 index 00000000000..93410fd4511 --- /dev/null +++ b/tests/components/androidtv_remote/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the Android TV Remote integration.""" +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_api.is_on = True + mock_api.current_app = "some app" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/androidtv_remote/test_init.py b/tests/components/androidtv_remote/test_init.py new file mode 100644 index 00000000000..f3f61eb268e --- /dev/null +++ b/tests/components/androidtv_remote/test_init.py @@ -0,0 +1,106 @@ +"""Tests for the Android TV Remote integration.""" +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock + +from androidtvremote2 import CannotConnect, InvalidAuth + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_api.disconnect.call_count == 1 + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry not ready.""" + mock_api.async_connect = AsyncMock(side_effect=CannotConnect()) + + 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 + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 0 + + +async def test_config_entry_reauth_at_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry needs reauth at setup.""" + mock_api.async_connect = AsyncMock(side_effect=InvalidAuth()) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 0 + + +async def test_config_entry_reauth_while_reconnecting( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote configuration entry needs reauth while reconnecting.""" + invalid_auth_callback: Callable | None = None + + def mocked_keep_reconnecting(callback: Callable): + nonlocal invalid_auth_callback + invalid_auth_callback = callback + + mock_api.keep_reconnecting.side_effect = mocked_keep_reconnecting + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + assert invalid_auth_callback is not None + invalid_auth_callback() + await hass.async_block_till_done() + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + + +async def test_disconnect_on_stop( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test we close the connection with the Android TV when Home Assistants stops.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_api.async_connect.call_count == 1 + assert mock_api.keep_reconnecting.call_count == 1 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 1 diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py new file mode 100644 index 00000000000..d0372b8a65a --- /dev/null +++ b/tests/components/androidtv_remote/test_remote.py @@ -0,0 +1,219 @@ +"""Tests for the Android TV Remote remote platform.""" +from collections.abc import Callable +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 + +REMOTE_ENTITY = "remote.my_android_tv" + + +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) + assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) + + is_on_updated_callback(True) + assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) + + current_app_updated_callback("activity1") + assert ( + hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" + ) + + is_available_updated_callback(False) + assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) + + is_available_updated_callback(True) + assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) + + +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 + + assert await hass.services.async_call( + "remote", + "turn_off", + {"entity_id": REMOTE_ENTITY}, + blocking=True, + ) + is_on_updated_callback(False) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + assert await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY}, + blocking=True, + ) + is_on_updated_callback(True) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + assert mock_api.send_key_command.call_count == 2 + + assert await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, + blocking=True, + ) + + mock_api.send_key_command.send_launch_app_command("activity1") + assert mock_api.send_key_command.call_count == 2 + assert mock_api.send_launch_app_command.call_count == 1 + + +async def test_remote_send_command( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service.""" + 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( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_LEFT", + "num_repeats": 2, + "delay_secs": 0.01, + }, + blocking=True, + ) + mock_api.send_key_command.assert_called_with("DPAD_LEFT", "SHORT") + assert mock_api.send_key_command.call_count == 2 + + +async def test_remote_send_command_multiple( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service with multiple commands.""" + 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( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": ["DPAD_LEFT", "DPAD_UP"], + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_LEFT", "SHORT"), + call("DPAD_UP", "SHORT"), + ] + + +async def test_remote_send_command_with_hold_secs( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test remote.send_command service with hold_secs.""" + 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( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_RIGHT", + "delay_secs": 0.01, + "hold_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_RIGHT", "START_LONG"), + call("DPAD_RIGHT", "END_LONG"), + ] + + +async def test_remote_connection_closed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test commands 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( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "DPAD_LEFT", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] + + mock_api.send_launch_app_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY, "activity": "activity1"}, + blocking=True, + ) + assert mock_api.send_launch_app_command.mock_calls == [call("activity1")] diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py new file mode 100644 index 00000000000..e0e31c84b7b --- /dev/null +++ b/tests/components/anova/__init__.py @@ -0,0 +1,85 @@ +"""Tests for the Anova integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from anova_wifi import ( + AnovaPrecisionCooker, + AnovaPrecisionCookerBinarySensor, + AnovaPrecisionCookerSensor, +) + +from homeassistant.components.anova.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +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, + }, +} + + +def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Anova", + data={ + CONF_USERNAME: "sample@gmail.com", + CONF_PASSWORD: "sample", + "devices": [(device_id, "type_sample")], + }, + unique_id="sample@gmail.com", + ) + entry.add_to_hass(hass) + return entry + + +async def async_init_integration( + hass: HomeAssistant, + skip_setup: bool = False, + error: str | None = None, +) -> ConfigEntry: + """Set up the Anova integration in Home Assistant.""" + with patch( + "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" + ) as update_patch, patch( + "homeassistant.components.anova.AnovaApi.authenticate" + ), patch( + "homeassistant.components.anova.AnovaApi.get_devices" + ) as device_patch: + update_patch.return_value = ONLINE_UPDATE + device_patch.return_value = [ + AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + ] + + entry = create_entry(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/anova/conftest.py b/tests/components/anova/conftest.py new file mode 100644 index 00000000000..34f713502dd --- /dev/null +++ b/tests/components/anova/conftest.py @@ -0,0 +1,85 @@ +"""Common fixtures for Anova.""" +from unittest.mock import AsyncMock, patch + +from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +import pytest + +from homeassistant.core import HomeAssistant + +from . import DEVICE_UNIQUE_ID + + +@pytest.fixture +async def anova_api( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova.""" + api_mock = AsyncMock() + + new_device = AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + + async def authenticate_side_effect(): + api_mock.jwt = "my_test_jwt" + + async def get_devices_side_effect(): + if not api_mock.existing_devices: + api_mock.existing_devices = [] + api_mock.existing_devices = api_mock.existing_devices + [new_device] + return [new_device] + + api_mock.authenticate.side_effect = authenticate_side_effect + api_mock.get_devices.side_effect = get_devices_side_effect + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_no_devices( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with no online devices.""" + api_mock = AsyncMock() + + async def authenticate_side_effect(): + api_mock.jwt = "my_test_jwt" + + async def get_devices_side_effect(): + raise NoDevicesFound() + + api_mock.authenticate.side_effect = authenticate_side_effect + api_mock.get_devices.side_effect = get_devices_side_effect + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_wrong_login( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a wrong login.""" + api_mock = AsyncMock() + + async def authenticate_side_effect(): + raise InvalidLogin() + + api_mock.authenticate.side_effect = authenticate_side_effect + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api diff --git a/tests/components/anova/test_config_flow.py b/tests/components/anova/test_config_flow.py new file mode 100644 index 00000000000..d1255876137 --- /dev/null +++ b/tests/components/anova/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test Anova config flow.""" + +from unittest.mock import patch + +from anova_wifi import AnovaPrecisionCooker, InvalidLogin, NoDevicesFound + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.anova.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import CONF_INPUT, DEVICE_UNIQUE_ID, create_entry + + +async def test_flow_user( + hass: HomeAssistant, +) -> None: + """Test user initialized flow.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + ) as auth_patch, patch( + "homeassistant.components.anova.AnovaApi.get_devices" + ) as device_patch, patch( + "homeassistant.components.anova.AnovaApi.authenticate" + ), patch( + "homeassistant.components.anova.config_flow.AnovaApi.get_devices" + ) as config_flow_device_patch: + auth_patch.return_value = True + device_patch.return_value = [ + AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + ] + config_flow_device_patch.return_value = [ + AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + ] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_USERNAME: "sample@gmail.com", + CONF_PASSWORD: "sample", + "devices": [(DEVICE_UNIQUE_ID, "type_sample")], + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate device.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + ) as auth_patch, patch( + "homeassistant.components.anova.AnovaApi.get_devices" + ) as device_patch, patch( + "homeassistant.components.anova.config_flow.AnovaApi.get_devices" + ) as config_flow_device_patch: + auth_patch.return_value = True + device_patch.return_value = [ + AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + ] + config_flow_device_patch.return_value = [ + AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) + ] + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_wrong_login(hass: HomeAssistant) -> None: + """Test incorrect login throwing error.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + side_effect=InvalidLogin, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_flow_unknown_error(hass: HomeAssistant) -> None: + """Test unknown error throwing error.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + side_effect=Exception(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_no_devices(hass: HomeAssistant) -> None: + """Test unknown error throwing error.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate" + ), patch( + "homeassistant.components.anova.config_flow.AnovaApi.get_devices", + side_effect=NoDevicesFound(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "no_devices_found"} diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py new file mode 100644 index 00000000000..cbd7231f366 --- /dev/null +++ b/tests/components/anova/test_init.py @@ -0,0 +1,75 @@ +"""Test init for Anova.""" + +from unittest.mock import patch + +from anova_wifi import AnovaApi + +from homeassistant.components.anova import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import ONLINE_UPDATE, async_init_integration, create_entry + + +async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test a successful setup entry.""" + await async_init_integration(hass) + state = hass.states.get("sensor.anova_precision_cooker_mode") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "Low water" + + +async def test_wrong_login( + hass: HomeAssistant, anova_api_wrong_login: AnovaApi +) -> None: + """Test for setup failure if connection to Anova is missing.""" + entry = create_entry(hass) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_new_devices(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test for if we find a new device on init.""" + entry = create_entry(hass, "test_device_2") + with patch( + "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" + ) as update_patch: + update_patch.return_value = ONLINE_UPDATE + assert len(entry.data["devices"]) == 1 + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(entry.data["devices"]) == 2 + + +async def test_device_cached_but_offline( + hass: HomeAssistant, anova_api_no_devices: AnovaApi +) -> None: + """Test if we have previously seen a device, but it was offline on startup.""" + entry = create_entry(hass) + + with patch( + "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" + ) as update_patch: + update_patch.return_value = ONLINE_UPDATE + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(entry.data["devices"]) == 1 + state = hass.states.get("sensor.anova_precision_cooker_mode") + assert state is not None + assert state.state == "Low water" + + +async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test successful unload of entry.""" + entry = await async_init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py new file mode 100644 index 00000000000..94ce61e5b21 --- /dev/null +++ b/tests/components/anova/test_sensor.py @@ -0,0 +1,61 @@ +"""Test the Anova sensors.""" + +from datetime import timedelta +import logging +from unittest.mock import patch + +from anova_wifi import AnovaApi, AnovaOffline + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import async_init_integration + +from tests.common import async_fire_time_changed + +LOGGER = logging.getLogger(__name__) + + +async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test setting up creates the sensors.""" + await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 8 + assert ( + hass.states.get("sensor.anova_precision_cooker_cook_time_remaining").state + == "0" + ) + assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0" + assert ( + hass.states.get("sensor.anova_precision_cooker_heater_temperature").state + == "20.87" + ) + assert hass.states.get("sensor.anova_precision_cooker_mode").state == "Low water" + assert hass.states.get("sensor.anova_precision_cooker_state").state == "No state" + assert ( + hass.states.get("sensor.anova_precision_cooker_target_temperature").state + == "23.33" + ) + assert ( + hass.states.get("sensor.anova_precision_cooker_water_temperature").state + == "21.33" + ) + assert ( + hass.states.get("sensor.anova_precision_cooker_triac_temperature").state + == "21.79" + ) + + +async def test_update_failed(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test updating data after the coordinator has been set up, but anova is offline.""" + await async_init_integration(hass) + await hass.async_block_till_done() + with patch( + "homeassistant.components.anova.AnovaPrecisionCooker.update", + side_effect=AnovaOffline(), + ): + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.anova_precision_cooker_water_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index c8696c71941..a9ef4328e86 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -117,7 +117,7 @@ async def test_flow_works(hass: HomeAssistant) -> None: context={CONF_SOURCE: SOURCE_USER}, ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONF_DATA diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 52388e694f5..6256d1dde9c 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -730,6 +730,52 @@ async def test_zeroconf_ip_change_via_secondary_identifier( assert len(mock_async_setup.mock_calls) == 2 assert entry.data[CONF_ADDRESS] == "127.0.0.1" assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"} + + +async def test_zeroconf_updates_identifiers_for_ignored_entries( + hass: HomeAssistant, mock_scan +) -> None: + """Test that an ignored config entry gets updated when the ip changes. + + Instead of checking only the unique id, all the identifiers + in the config entry are checked + """ + entry = MockConfigEntry( + domain="apple_tv", + unique_id="aa:bb:cc:dd:ee:ff", + source=config_entries.SOURCE_IGNORE, + data={CONF_IDENTIFIERS: ["mrpid"], CONF_ADDRESS: "127.0.0.2"}, + ) + unrelated_entry = MockConfigEntry( + domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"} + ) + unrelated_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert ( + len(mock_async_setup.mock_calls) == 0 + ) # Should not be called because entry is ignored + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"} async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> None: diff --git a/tests/components/assist_pipeline/__init__.py b/tests/components/assist_pipeline/__init__.py new file mode 100644 index 00000000000..40aa48fbc54 --- /dev/null +++ b/tests/components/assist_pipeline/__init__.py @@ -0,0 +1,55 @@ +"""Tests for the Voice Assistant integration.""" +MANY_LANGUAGES = [ + "ar", + "bg", + "bn", + "ca", + "cs", + "da", + "de", + "de-CH", + "el", + "en", + "es", + "fa", + "fi", + "fr", + "fr-CA", + "gl", + "gu", + "he", + "hi", + "hr", + "hu", + "id", + "is", + "it", + "ka", + "kn", + "lb", + "lt", + "lv", + "ml", + "mn", + "ms", + "nb", + "nl", + "pl", + "pt", + "pt-br", + "ro", + "ru", + "sk", + "sl", + "sr", + "sv", + "sw", + "te", + "tr", + "uk", + "ur", + "vi", + "zh-cn", + "zh-hk", + "zh-tw", +] diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py new file mode 100644 index 00000000000..1df52859ed9 --- /dev/null +++ b/tests/components/assist_pipeline/conftest.py @@ -0,0 +1,266 @@ +"""Test fixtures for voice assistant.""" +from __future__ import annotations + +from collections.abc import AsyncIterable, Generator +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components import stt, tts +from homeassistant.components.assist_pipeline import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import PipelineStorageCollection +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + 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" + + +class BaseProvider: + """Mock STT provider.""" + + _supported_languages = ["en-US"] + + def __init__(self, text: str) -> None: + """Init test provider.""" + self.text = text + self.received: list[bytes] = [] + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._supported_languages + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream.""" + async for data in stream: + if not data: + break + self.received.append(data) + return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) + + +class MockSttProvider(BaseProvider, stt.Provider): + """Mock provider.""" + + +class MockSttProviderEntity(BaseProvider, stt.SpeechToTextEntity): + """Mock provider entity.""" + + _attr_name = "Mock STT" + + +class MockTTSProvider(tts.Provider): + """Mock TTS provider.""" + + name = "Test" + _supported_languages = ["en-US"] + _supported_voices = { + "en-US": [ + tts.Voice("james_earl_jones", "James Earl Jones"), + tts.Voice("fran_drescher", "Fran Drescher"), + ] + } + + @property + def default_language(self) -> str: + """Return the default language.""" + return "en" + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return self._supported_languages + + @callback + def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None: + """Return a list of supported voices for a language.""" + return self._supported_voices.get(language) + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age", tts.ATTR_AUDIO_OUTPUT] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS data.""" + return ("mp3", b"") + + +class MockTTSPlatform(MockPlatform): + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA + + def __init__(self, *, async_get_engine, **kwargs): + """Initialize the tts platform.""" + super().__init__(**kwargs) + self.async_get_engine = async_get_engine + + +@pytest.fixture +async def mock_tts_provider(hass) -> MockTTSProvider: + """Mock TTS provider.""" + return MockTTSProvider() + + +@pytest.fixture +async def mock_stt_provider() -> MockSttProvider: + """Mock STT provider.""" + return MockSttProvider(_TRANSCRIPT) + + +@pytest.fixture +def mock_stt_provider_entity() -> MockSttProviderEntity: + """Test provider entity fixture.""" + return MockSttProviderEntity(_TRANSCRIPT) + + +class MockSttPlatform(MockPlatform): + """Provide a fake STT platform.""" + + def __init__(self, *, async_get_engine, **kwargs): + """Initialize the stt platform.""" + super().__init__(**kwargs) + self.async_get_engine = async_get_engine + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield + + +@pytest.fixture +async def init_supporting_components( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + 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.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, stt.DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, stt.DOMAIN) + return True + + async def async_setup_entry_stt_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([mock_stt_provider_entity]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + mock_platform( + hass, + "test.tts", + MockTTSPlatform( + async_get_engine=AsyncMock(return_value=mock_tts_provider), + ), + ) + mock_platform( + hass, + "test.stt", + MockSttPlatform( + async_get_engine=AsyncMock(return_value=mock_stt_provider), + async_setup_entry=async_setup_entry_stt_platform, + ), + ) + mock_platform(hass, "test.config_flow") + + 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", {}) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.fixture +async def init_components(hass: HomeAssistant, init_supporting_components): + """Initialize relevant components with empty configs.""" + + assert await async_setup_component(hass, "assist_pipeline", {}) + + +@pytest.fixture +def pipeline_storage(hass: HomeAssistant, init_components) -> PipelineStorageCollection: + """Return pipeline storage collection.""" + return hass.data[DOMAIN].pipeline_store diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr new file mode 100644 index 00000000000..619c59606ed --- /dev/null +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -0,0 +1,262 @@ +# serializer version: 1 +# name: test_pipeline_from_audio_stream_auto + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_from_audio_stream_entity + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'stt.mock_stt', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en-US', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'Arnold Schwarzenegger', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_from_audio_stream_legacy + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en-US', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'Arnold Schwarzenegger', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr similarity index 57% rename from tests/components/voice_assistant/snapshots/test_websocket.ambr rename to tests/components/assist_pipeline/snapshots/test_websocket.ambr index c18af44b21c..a2e5ac72b07 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -1,16 +1,17 @@ # serializer version: 1 # name: test_audio_pipeline dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_audio_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -30,8 +31,9 @@ # --- # name: test_audio_pipeline.3 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'test transcript', + 'language': 'en', }) # --- # name: test_audio_pipeline.4 @@ -44,7 +46,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -58,46 +60,129 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'default', + 'engine': 'test', + 'language': 'en-US', 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_debug + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_debug.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_debug.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_debug.3 + dict({ + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_debug.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline_debug.5 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_debug.6 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }) # --- # name: test_intent_failed dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_intent_failed.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', + 'language': 'en', }) # --- # name: test_intent_timeout dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 0.1, }), }) # --- # name: test_intent_timeout.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', + 'language': 'en', }) # --- # name: test_intent_timeout.2 @@ -108,10 +193,11 @@ # --- # name: test_stt_provider_missing dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': 'en', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- @@ -123,23 +209,24 @@ 'channel': 1, 'codec': 'pcm', 'format': 'wav', - 'language': 'en-US', + 'language': 'en', 'sample_rate': 16000, }), }) # --- # name: test_stt_stream_failed dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_stt_stream_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -152,17 +239,19 @@ # --- # name: test_text_only_pipeline dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_text_only_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', + 'language': 'en', }) # --- # name: test_text_only_pipeline.2 @@ -175,7 +264,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -195,16 +284,19 @@ # --- # name: test_tts_failed dict({ - 'language': 'en-US', - 'pipeline': 'en-US', + 'language': 'en', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_tts_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', + 'language': 'en-US', 'tts_input': 'Lights are on.', + 'voice': 'james_earl_jones', }) # --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py new file mode 100644 index 00000000000..6fb6bf61d96 --- /dev/null +++ b/tests/components/assist_pipeline/test_init.py @@ -0,0 +1,243 @@ +"""Test Voice Assistant init.""" +from dataclasses import asdict +from unittest.mock import ANY + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import assist_pipeline, stt +from homeassistant.core import Context, HomeAssistant + +from .conftest import MockSttProvider, MockSttProviderEntity + +from tests.typing import WebSocketGenerator + + +def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: + """Process events to remove dynamic values.""" + processed = [] + for event in events: + as_dict = asdict(event) + as_dict.pop("timestamp") + if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: + as_dict["data"]["pipeline"] = ANY + processed.append(as_dict) + + return processed + + +async def test_pipeline_from_audio_stream_auto( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream. + + In this test, no pipeline is specified. + """ + + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + Context(), + events.append, + stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + audio_data(), + ) + + assert process_events(events) == snapshot + assert mock_stt_provider.received == [b"part1", b"part2"] + + +async def test_pipeline_from_audio_stream_legacy( + 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, a pipeline using a legacy stt engine is used. + """ + client = await hass_ws_client(hass) + + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + # Create a pipeline using an stt entity + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "test_name", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + + # Use the created pipeline + 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=pipeline_id, + ) + + assert process_events(events) == snapshot + assert mock_stt_provider.received == [b"part1", b"part2"] + + +async def test_pipeline_from_audio_stream_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_stt_provider_entity: MockSttProviderEntity, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream. + + In this test, a pipeline using am stt entity is used. + """ + client = await hass_ws_client(hass) + + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + # Create a pipeline using an stt entity + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "test_name", + "stt_engine": mock_stt_provider_entity.entity_id, + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + + # Use the created pipeline + 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=pipeline_id, + ) + + assert process_events(events) == snapshot + assert mock_stt_provider_entity.received == [b"part1", b"part2"] + + +async def test_pipeline_from_audio_stream_no_stt( + 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 support stt + """ + client = await hass_ws_client(hass) + + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + # Create a pipeline without stt support + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": "test", + "tts_language": "en-AU", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + + # Try to use the created pipeline + with pytest.raises(assist_pipeline.pipeline.PipelineRunValidationError): + 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=pipeline_id, + ) + + assert not events diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py new file mode 100644 index 00000000000..4c71b4aedbd --- /dev/null +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -0,0 +1,421 @@ +"""Websocket tests for Voice Assistant integration.""" +from typing import Any +from unittest.mock import ANY, AsyncMock, patch + +import pytest + +from homeassistant.components.assist_pipeline.const import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import ( + STORAGE_KEY, + STORAGE_VERSION, + Pipeline, + PipelineData, + PipelineStorageCollection, + async_create_default_pipeline, + async_get_pipeline, + async_get_pipelines, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.setup import async_setup_component + +from . import MANY_LANGUAGES +from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTSProvider + +from tests.common import MockModule, flush_store, mock_integration, mock_platform + + +async def test_load_datasets(hass: HomeAssistant, init_components) -> None: + """Make sure that we can load/save data correctly.""" + + pipelines = [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "stt_language": "language_1", + "tts_engine": "tts_engine_1", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_1", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + }, + ] + pipeline_ids = [] + + pipeline_data: PipelineData = hass.data[DOMAIN] + store1 = pipeline_data.pipeline_store + for pipeline in pipelines: + pipeline_ids.append((await store1.async_create_item(pipeline)).id) + assert len(store1.data) == 4 # 3 manually created plus a default pipeline + assert store1.async_get_preferred_item() == list(store1.data)[0] + + await store1.async_delete_item(pipeline_ids[1]) + assert len(store1.data) == 3 + + store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY)) + await flush_store(store1.store) + await store2.async_load() + + assert len(store2.data) == 3 + + assert store1.data is not store2.data + assert store1.data == store2.data + assert store1.async_get_preferred_item() == store2.async_get_preferred_item() + + +async def test_loading_datasets_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored datasets on start.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "stt_language": "language_1", + "tts_engine": "tts_engine_1", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + }, + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 3 + assert store.async_get_preferred_item() == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + + +async def test_create_default_pipeline( + hass: HomeAssistant, init_supporting_components +) -> None: + """Test async_create_default_pipeline.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + assert await async_create_default_pipeline(hass, "bla", "bla") is None + assert await async_create_default_pipeline(hass, "test", "test") == Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=ANY, + language="en", + name="Home Assistant", + stt_engine="test", + stt_language="en-US", + tts_engine="test", + tts_language="en-US", + tts_voice="james_earl_jones", + ) + + +async def test_get_pipeline(hass: HomeAssistant) -> None: + """Test async_get_pipeline.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Test we get the preferred pipeline if none is specified + pipeline = async_get_pipeline(hass, None) + assert pipeline.id == store.async_get_preferred_item() + + # Test getting a specific pipeline + assert pipeline is async_get_pipeline(hass, pipeline.id) + + +async def test_get_pipelines(hass: HomeAssistant) -> None: + """Test async_get_pipelines.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + pipelines = async_get_pipelines(hass) + assert list(pipelines) == [ + Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=ANY, + language="en", + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + ) + ] + + +@pytest.mark.parametrize( + ("ha_language", "ha_country", "conv_language", "pipeline_language"), + [ + ("en", None, "en", "en"), + ("de", "de", "de", "de"), + ("de", "ch", "de-CH", "de"), + ("en", "us", "en", "en"), + ("en", "uk", "en", "en"), + ("pt", "pt", "pt", "pt"), + ("pt", "br", "pt-br", "pt"), + ], +) +async def test_default_pipeline_no_stt_tts( + hass: HomeAssistant, + ha_language: str, + ha_country: str | None, + conv_language: str, + pipeline_language: str, +) -> None: + """Test async_get_pipeline.""" + hass.config.country = ha_country + hass.config.language = ha_language + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Check the default pipeline + pipeline = async_get_pipeline(hass, None) + assert pipeline == Pipeline( + conversation_engine="homeassistant", + conversation_language=conv_language, + id=pipeline.id, + language=pipeline_language, + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + ) + + +@pytest.mark.parametrize( + ( + "ha_language", + "ha_country", + "conv_language", + "pipeline_language", + "stt_language", + "tts_language", + ), + [ + ("en", None, "en", "en", "en", "en"), + ("de", "de", "de", "de", "de", "de"), + ("de", "ch", "de-CH", "de", "de-CH", "de-CH"), + ("en", "us", "en", "en", "en", "en"), + ("en", "uk", "en", "en", "en", "en"), + ("pt", "pt", "pt", "pt", "pt", "pt"), + ("pt", "br", "pt-br", "pt", "pt-br", "pt-br"), + ], +) +async def test_default_pipeline( + hass: HomeAssistant, + init_supporting_components, + mock_stt_provider: MockSttProvider, + mock_tts_provider: MockTTSProvider, + ha_language: str, + ha_country: str | None, + conv_language: str, + pipeline_language: str, + stt_language: str, + tts_language: str, +) -> None: + """Test async_get_pipeline.""" + hass.config.country = ha_country + hass.config.language = ha_language + + with patch.object( + mock_stt_provider, "_supported_languages", MANY_LANGUAGES + ), patch.object(mock_tts_provider, "_supported_languages", MANY_LANGUAGES): + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Check the default pipeline + pipeline = async_get_pipeline(hass, None) + assert pipeline == Pipeline( + conversation_engine="homeassistant", + conversation_language=conv_language, + id=pipeline.id, + language=pipeline_language, + name="Home Assistant", + stt_engine="test", + stt_language=stt_language, + tts_engine="test", + tts_language=tts_language, + tts_voice=None, + ) + + +async def test_default_pipeline_unsupported_stt_language( + hass: HomeAssistant, + init_supporting_components, + mock_stt_provider: MockSttProvider, +) -> None: + """Test async_get_pipeline.""" + with patch.object(mock_stt_provider, "_supported_languages", ["smurfish"]): + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Check the default pipeline + pipeline = async_get_pipeline(hass, None) + assert pipeline == Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=pipeline.id, + language="en", + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine="test", + tts_language="en-US", + tts_voice="james_earl_jones", + ) + + +async def test_default_pipeline_unsupported_tts_language( + hass: HomeAssistant, + init_supporting_components, + mock_tts_provider: MockTTSProvider, +) -> None: + """Test async_get_pipeline.""" + with patch.object(mock_tts_provider, "_supported_languages", ["smurfish"]): + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Check the default pipeline + pipeline = async_get_pipeline(hass, None) + assert pipeline == Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=pipeline.id, + language="en", + name="Home Assistant", + stt_engine="test", + stt_language="en-US", + tts_engine=None, + tts_language=None, + tts_voice=None, + ) + + +async def test_default_pipeline_cloud( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_tts_provider: MockTTSProvider, +) -> None: + """Test async_get_pipeline.""" + + mock_integration(hass, MockModule("cloud")) + mock_platform( + hass, + "cloud.tts", + MockTTSPlatform( + async_get_engine=AsyncMock(return_value=mock_tts_provider), + ), + ) + mock_platform( + hass, + "cloud.stt", + MockSttPlatform( + async_get_engine=AsyncMock(return_value=mock_stt_provider), + ), + ) + mock_platform(hass, "test.config_flow") + + assert await async_setup_component(hass, "tts", {"tts": {"platform": "cloud"}}) + assert await async_setup_component(hass, "stt", {"stt": {"platform": "cloud"}}) + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 1 + + # Check the default pipeline + pipeline = async_get_pipeline(hass, None) + assert pipeline == Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=pipeline.id, + language="en", + name="Home Assistant Cloud", + stt_engine="cloud", + stt_language="en-US", + tts_engine="cloud", + tts_language="en-US", + tts_voice="james_earl_jones", + ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py new file mode 100644 index 00000000000..30874e7b756 --- /dev/null +++ b/tests/components/assist_pipeline/test_select.py @@ -0,0 +1,130 @@ +"""Test select entity.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.assist_pipeline import Pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineStorageCollection +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform + + +class SelectPlatform(MockPlatform): + """Fake select platform.""" + + # pylint: disable=method-hidden + async def async_setup_entry( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up fake select platform.""" + async_add_entities([AssistPipelineSelect(hass, "test")]) + + +@pytest.fixture +async def init_select(hass: HomeAssistant, init_components) -> ConfigEntry: + """Initialize select entity.""" + mock_entity_platform(hass, "select.assist_pipeline", SelectPlatform()) + config_entry = MockConfigEntry(domain="assist_pipeline") + assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + return config_entry + + +@pytest.fixture +async def pipeline_1( + hass: HomeAssistant, init_select, pipeline_storage: PipelineStorageCollection +) -> Pipeline: + """Create a pipeline.""" + return await pipeline_storage.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + } + ) + + +@pytest.fixture +async def pipeline_2( + hass: HomeAssistant, init_select, pipeline_storage: PipelineStorageCollection +) -> Pipeline: + """Create a pipeline.""" + return await pipeline_storage.async_create_item( + { + "name": "Test 2", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + } + ) + + +async def test_select_entity_changing_pipelines( + hass: HomeAssistant, + init_select: ConfigEntry, + pipeline_1: Pipeline, + pipeline_2: Pipeline, + pipeline_storage: PipelineStorageCollection, +) -> None: + """Test entity tracking pipeline changes.""" + config_entry = init_select # nicer naming + + state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state is not None + assert state.state == "preferred" + assert state.attributes["options"] == [ + "preferred", + "Home Assistant", + pipeline_1.name, + pipeline_2.name, + ] + + # Change select to new pipeline + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.assist_pipeline_test_pipeline", + "option": pipeline_2.name, + }, + blocking=True, + ) + + state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state.state == pipeline_2.name + + # Reload config entry to test selected option persists + assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") + assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + + state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state.state == pipeline_2.name + + # Remove selected pipeline + await pipeline_storage.async_delete_item(pipeline_2.id) + + state = hass.states.get("select.assist_pipeline_test_pipeline") + assert state.state == "preferred" + assert state.attributes["options"] == [ + "preferred", + "Home Assistant", + pipeline_1.name, + ] diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py new file mode 100644 index 00000000000..3a5c763ee5c --- /dev/null +++ b/tests/components/assist_pipeline/test_vad.py @@ -0,0 +1,38 @@ +"""Tests for webrtcvad voice command segmenter.""" +from unittest.mock import patch + +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter + +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +def test_silence() -> None: + """Test that 3 seconds of silence does not trigger a voice command.""" + segmenter = VoiceCommandSegmenter() + + # True return value indicates voice command has not finished + assert segmenter.process(bytes(_ONE_SECOND * 3)) + + +def test_speech() -> None: + """Test that silence + speech + silence triggers a voice command.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + + # silence + assert segmenter.process(bytes(_ONE_SECOND)) + + # "speech" + assert segmenter.process(bytes([255] * _ONE_SECOND)) + + # silence + # False return value indicates voice command is finished + assert not segmenter.process(bytes(_ONE_SECOND)) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py new file mode 100644 index 00000000000..c71d0526fe6 --- /dev/null +++ b/tests/components/assist_pipeline/test_websocket.py @@ -0,0 +1,1299 @@ +"""Websocket tests for Voice Assistant integration.""" +import asyncio +from unittest.mock import ANY, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.assist_pipeline.const import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import Pipeline, PipelineData +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_text_only_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with text input (no STT/TTS).""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] is None + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_audio_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with audio input/output.""" + events = [] + 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": 44100, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([1])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text to speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] is None + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_intent_timeout( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test partial pipeline run with conversation agent timeout.""" + events = [] + client = await hass_ws_client(hass) + + async def sleepy_converse(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.conversation.async_converse", + new=sleepy_converse, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + "timeout": 0.1, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # timeout error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_text_pipeline_timeout( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test text-only pipeline run with immediate timeout.""" + events = [] + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineInput.execute", + new=sleepy_run, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # timeout error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_intent_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test text-only pipeline run with conversation agent error.""" + events = [] + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_converse", + side_effect=RuntimeError, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent start + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "intent-failed" + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_audio_pipeline_timeout( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test audio pipeline run with immediate timeout.""" + events = [] + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineInput.execute", + new=sleepy_run, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 44100, + }, + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # timeout error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "timeout" + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_stt_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "homeassistant.components.stt.async_get_provider", + return_value=None, + ): + 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": 44100, + }, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-missing" + + +async def test_stt_stream_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + events = [] + client = await hass_ws_client(hass) + + with patch( + "tests.components.assist_pipeline.conftest.MockSttProvider.async_process_audio_stream", + side_effect=RuntimeError, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 44100, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-stream-failed" + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_tts_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test pipeline run with text to speech error.""" + events = [] + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=RuntimeError, + ): + 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 msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # tts start + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # tts error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "tts-failed" + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_invalid_stage_order( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with invalid stage order.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "stt", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + + +async def test_add_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can add a pipeline.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "id": ANY, + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + + assert len(pipeline_store.data) == 2 + pipeline = pipeline_store.data[msg["result"]["id"]] + assert pipeline == Pipeline( + conversation_engine="test_conversation_engine", + conversation_language="test_language", + id=msg["result"]["id"], + language="test_language", + name="test_name", + stt_engine="test_stt_engine", + stt_language="test_language", + tts_engine="test_tts_engine", + tts_language="test_language", + tts_voice="Arnold Schwarzenegger", + ) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "language": "test_language", + "name": "test_name", + } + ) + msg = await client.receive_json() + assert not msg["success"] + + +async def test_add_pipeline_missing_language( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can't add a pipeline without specifying stt or tts language.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + assert len(pipeline_store.data) == 1 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": None, + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert len(pipeline_store.data) == 1 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": None, + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert len(pipeline_store.data) == 1 + + +async def test_delete_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can delete a pipeline.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id_1 = msg["result"]["id"] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id_2 = msg["result"]["id"] + + assert len(pipeline_store.data) == 3 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/set_preferred", + "pipeline_id": pipeline_id_1, + } + ) + msg = await client.receive_json() + assert msg["success"] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/delete", + "pipeline_id": pipeline_id_1, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_allowed", + "message": f"Item {pipeline_id_1} preferred.", + } + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/delete", + "pipeline_id": pipeline_id_2, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert len(pipeline_store.data) == 2 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/delete", + "pipeline_id": pipeline_id_2, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": f"Unable to find pipeline_id {pipeline_id_2}", + } + + +async def test_get_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can get a pipeline.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/get", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "homeassistant", + "conversation_language": "en", + "id": ANY, + "language": "en", + "name": "Home Assistant", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "james_earl_jones", + } + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/get", + "pipeline_id": "no_such_pipeline", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "Unable to find pipeline_id no_such_pipeline", + } + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + assert len(pipeline_store.data) == 2 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/get", + "pipeline_id": pipeline_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "id": pipeline_id, + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + + +async def test_list_pipelines( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can list pipelines.""" + client = await hass_ws_client(hass) + hass.data[DOMAIN] + + await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "pipelines": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "en", + "id": ANY, + "language": "en", + "name": "Home Assistant", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "james_earl_jones", + } + ], + "preferred_pipeline": ANY, + } + + +async def test_update_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can list pipelines.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/update", + "conversation_engine": "new_conversation_engine", + "conversation_language": "new_conversation_language", + "language": "new_language", + "name": "new_name", + "pipeline_id": "no_such_pipeline", + "stt_engine": "new_stt_engine", + "stt_language": "new_stt_language", + "tts_engine": "new_tts_engine", + "tts_language": "new_tts_language", + "tts_voice": "new_tts_voice", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "Unable to find pipeline_id no_such_pipeline", + } + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + assert len(pipeline_store.data) == 2 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/update", + "conversation_engine": "new_conversation_engine", + "conversation_language": "new_conversation_language", + "language": "new_language", + "name": "new_name", + "pipeline_id": pipeline_id, + "stt_engine": "new_stt_engine", + "stt_language": "new_stt_language", + "tts_engine": "new_tts_engine", + "tts_language": "new_tts_language", + "tts_voice": "new_tts_voice", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "new_conversation_engine", + "conversation_language": "new_conversation_language", + "id": pipeline_id, + "language": "new_language", + "name": "new_name", + "stt_engine": "new_stt_engine", + "stt_language": "new_stt_language", + "tts_engine": "new_tts_engine", + "tts_language": "new_tts_language", + "tts_voice": "new_tts_voice", + } + + assert len(pipeline_store.data) == 2 + pipeline = pipeline_store.data[pipeline_id] + assert pipeline == Pipeline( + conversation_engine="new_conversation_engine", + conversation_language="new_conversation_language", + id=pipeline_id, + language="new_language", + name="new_name", + stt_engine="new_stt_engine", + stt_language="new_stt_language", + tts_engine="new_tts_engine", + tts_language="new_tts_language", + tts_voice="new_tts_voice", + ) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/update", + "conversation_engine": "new_conversation_engine", + "conversation_language": "new_conversation_language", + "language": "new_language", + "name": "new_name", + "pipeline_id": pipeline_id, + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "new_conversation_engine", + "conversation_language": "new_conversation_language", + "id": pipeline_id, + "language": "new_language", + "name": "new_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + } + + pipeline = pipeline_store.data[pipeline_id] + assert pipeline == Pipeline( + conversation_engine="new_conversation_engine", + conversation_language="new_conversation_language", + id=pipeline_id, + language="new_language", + name="new_name", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + ) + + +async def test_set_preferred_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test updating the preferred pipeline.""" + client = await hass_ws_client(hass) + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "test_conversation_engine", + "conversation_language": "test_language", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "stt_language": "test_language", + "tts_engine": "test_tts_engine", + "tts_language": "test_language", + "tts_voice": "Arnold Schwarzenegger", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id_1 = msg["result"]["id"] + + assert pipeline_store.async_get_preferred_item() != pipeline_id_1 + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/set_preferred", + "pipeline_id": pipeline_id_1, + } + ) + msg = await client.receive_json() + assert msg["success"] + + assert pipeline_store.async_get_preferred_item() == pipeline_id_1 + + +async def test_set_preferred_pipeline_wrong_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test updating the preferred pipeline.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "assist_pipeline/pipeline/set_preferred", "pipeline_id": "don_t_exist"} + ) + msg = await client.receive_json() + assert msg["error"]["code"] == "not_found" + + +async def test_audio_pipeline_debug( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test debug listing events from a pipeline run with audio input/output.""" + events = [] + 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": 44100, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([1])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text to speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] is None + events.append(msg["event"]) + + # Get the id of the pipeline + await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["pipelines"]) == 1 + + pipeline_id = msg["result"]["pipelines"][0]["id"] + + # Get the id for the run + await client.send_json_auto_id( + {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"pipeline_runs": [ANY]} + + pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_pipeline_debug_list_runs_wrong_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test debug listing events from a pipeline.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": "blah"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"pipeline_runs": []} + + +async def test_pipeline_debug_get_run_wrong_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test debug listing events from a pipeline.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": "blah", + "pipeline_run_id": "blah", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "pipeline_id blah not found", + } + + +async def test_pipeline_debug_get_run_wrong_pipeline_run( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test debug listing events from a pipeline.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # consume events + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + + # Get the id of the pipeline + await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["pipelines"]) == 1 + pipeline_id = msg["result"]["pipelines"][0]["id"] + + # get debug data for the wrong run + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": "blah", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "pipeline_run_id blah not found", + } + + +async def test_list_pipeline_languages( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test listing pipeline languages.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "assist_pipeline/language/list"}) + + # result + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"languages": ["en"]} diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index b8a1c52f473..3859e0c857c 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1539,6 +1539,7 @@ async def test_automation_restore_last_triggered_with_initial_state( async def test_extraction_functions(hass: HomeAssistant) -> None: """Test extraction functions.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) assert await async_setup_component( hass, diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index d4fde85f501..4aa84dbd602 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -50,7 +50,9 @@ async def test_exclude_attributes( assert ["hello.world"] == calls[0].data.get(ATTR_ENTITY_ID) await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) == 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py index 5a1a83fa0fb..b9f466174af 100644 --- a/tests/components/awair/test_config_flow.py +++ b/tests/components/awair/test_config_flow.py @@ -29,7 +29,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.MENU - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_invalid_access_token(hass: HomeAssistant) -> None: diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 2c5b3a50513..d535b4bcb1f 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -56,7 +56,7 @@ async def test_flow_manual_configuration( ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -91,7 +91,7 @@ async def test_manual_configuration_update_configuration( ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" mock_vapix_requests("2.3.4.5") result = await hass.config_entries.flow.async_configure( @@ -117,7 +117,7 @@ async def test_flow_fails_faulty_credentials(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "homeassistant.components.axis.config_flow.get_axis_device", @@ -143,7 +143,7 @@ async def test_flow_fails_cannot_connect(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "homeassistant.components.axis.config_flow.get_axis_device", @@ -182,7 +182,7 @@ async def test_flow_create_entry_multiple_existing_entries_of_same_model( ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -223,7 +223,7 @@ async def test_reauth_flow_update_configuration( ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" mock_vapix_requests("2.3.4.5") result = await hass.config_entries.flow.async_configure( @@ -321,7 +321,7 @@ async def test_discovery_flow( ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/azure_event_hub/conftest.py b/tests/components/azure_event_hub/conftest.py index 1af24d4a6fa..fac93e32ab9 100644 --- a/tests/components/azure_event_hub/conftest.py +++ b/tests/components/azure_event_hub/conftest.py @@ -59,7 +59,10 @@ async def mock_entry_fixture(hass, filter_schema, mock_create_batch, mock_send_b utcnow() + timedelta(seconds=entry.options[CONF_SEND_INTERVAL]), ) await hass.async_block_till_done() - return entry + + yield entry + + await entry.async_unload(hass) # fixtures for init tests diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index 44ead926e46..95c415b8909 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -5,7 +5,6 @@ from pybalboa.exceptions import SpaConnectionError from homeassistant import config_entries, data_entry_flow from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -111,7 +110,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 66ef0c4a142..d9901a68b0f 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -899,6 +899,48 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( mock_config_flow.reset_mock() +async def test_discovery_match_by_service_data_uuid_bthome( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None +) -> None: + """Test bluetooth discovery match by service_data_uuid for bthome.""" + mock_bt = [ + { + "domain": "bthome", + "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb", + }, + ] + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + await async_setup_with_default_adapter(hass) + + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + device = generate_ble_device("44:44:33:11:23:45", "Shelly Button") + button_adv = generate_advertisement_data( + local_name="Shelly Button", + service_uuids=[], + manufacturer_data={}, + service_data={"0000fcd2-0000-1000-8000-00805f9b34fb": b"@\x00k\x01d:\x01"}, + ) + # 1st discovery should generate a flow because the service data uuid matches + inject_advertisement(hass, device, button_adv) + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 + mock_config_flow.reset_mock() + + # 2nd discovery should not generate a flow because the + # we already saw an advertisement with the service_data_uuid + inject_advertisement(hass, device, button_adv) + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 0 + mock_config_flow.reset_mock() + + async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None ) -> None: diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 9d8d20bec5e..bd1aaea5b6f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -822,12 +822,22 @@ async def test_goes_unavailable_connectable_only_and_recovers( unsetup_not_connectable_scanner() -async def test_goes_unavailable_dismisses_discovery( +async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( hass: HomeAssistant, mock_bluetooth_adapters: None ) -> None: - """Test that unavailable will dismiss any active discoveries.""" - assert await async_setup_component(hass, bluetooth.DOMAIN, {}) - await hass.async_block_till_done() + """Test that unavailable will dismiss any active discoveries and make device discoverable again.""" + mock_bt = [ + { + "domain": "switchbot", + "service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb", + "connectable": False, + }, + ] + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() assert async_scanner_count(hass, connectable=False) == 0 switchbot_device_non_connectable = generate_ble_device( @@ -896,9 +906,15 @@ async def test_goes_unavailable_dismisses_discovery( cancel_connectable_scanner = _get_manager().async_register_scanner( non_connectable_scanner, True ) - non_connectable_scanner.inject_advertisement( - switchbot_device_non_connectable, switchbot_device_adv - ) + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + non_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, switchbot_device_adv + ) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None assert async_scanner_count(hass, connectable=True) == 1 assert len(callbacks) == 1 @@ -950,6 +966,27 @@ async def test_goes_unavailable_dismisses_discovery( assert len(mock_async_progress_by_init_data_type.mock_calls) == 1 assert mock_async_abort.mock_calls[0][1][0] == "mock_flow_id" + # Test that if the device comes back online, it can be discovered again + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + new_switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-60, + ) + non_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, new_switchbot_device_adv + ) + await hass.async_block_till_done() + + assert ( + "44:44:33:11:23:45" + in non_connectable_scanner.discovered_devices_and_advertisement_data + ) + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + cancel_unavailable() cancel() diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 8331a8b6b76..746f52537cb 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -503,7 +503,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab }, rssi=-30, ) - switchbot_proxy_device_no_connection_slot.metadata["delegate"] = 0 switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], @@ -518,7 +517,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, ) - switchbot_proxy_device_has_connection_slot.metadata["delegate"] = 0 switchbot_proxy_device_has_connection_slot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], @@ -532,7 +530,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab {}, rssi=-100, ) - switchbot_device.metadata["delegate"] = 0 switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 887df4da603..73e8f9a9b92 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,6 +1,9 @@ """Fixtures for BMW tests.""" +from unittest.mock import AsyncMock + from bimmer_connected.api.authentication import MyBMWAuthentication +from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest from . import mock_login, mock_vehicles @@ -11,5 +14,11 @@ async def bmw_fixture(monkeypatch): """Patch the MyBMW Login and mock HTTP calls.""" monkeypatch.setattr(MyBMWAuthentication, "login", mock_login) + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), + ) + with mock_vehicles(): yield mock_vehicles diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json new file mode 100644 index 00000000000..af850f1ff2c --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json @@ -0,0 +1,80 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "chargingMode": "Sofort laden", + "chargingModeSemantics": "Sofort laden", + "departureTimer": ["Aus"], + "departureTimerSemantics": "Aus", + "preconditionForDeparture": "Aus", + "showDepartureTimers": false + }, + "chargingFlap": { + "permanentlyUnlockLabel": "Aus" + }, + "chargingSettings": { + "acCurrentLimitLabel": "16A", + "acCurrentLimitLabelSemantics": "16 Ampere", + "chargingTargetLabel": "80%", + "dcLoudnessLabel": "Nicht begrenzt", + "unlockCableAutomaticallyLabel": "Aus" + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "NO_PRESELECTION", + "endTimeSlot": "0001-01-01T00:00:00", + "startTimeSlot": "0001-01-01T00:00:00", + "type": "CHARGING_IMMEDIATELY" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [], + "id": 1, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 2, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "chargingFlapDetail": { + "isPermanentlyUnlock": false + }, + "chargingSettingsDetail": { + "acLimit": { + "current": { + "unit": "A", + "value": 16 + }, + "isUnlimited": false, + "max": 32, + "min": 6, + "values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32] + }, + "chargingTarget": 80, + "dcLoudness": "UNLIMITED_LOUD", + "isUnlockCableActive": false, + "minChargingTargetToWarning": 0 + }, + "servicePack": "WAVE_01" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json new file mode 100644 index 00000000000..f954fb103ae --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json @@ -0,0 +1,50 @@ +[ + { + "appVehicleType": "DEMO", + "attributes": { + "a4aType": "NOT_SUPPORTED", + "bodyType": "G26", + "brand": "BMW", + "color": 4284245350, + "countryOfOrigin": "DE", + "driveTrain": "ELECTRIC", + "driverGuideInfo": { + "androidAppScheme": "com.bmwgroup.driversguide.row", + "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", + "iosAppScheme": "bmwdriversguide:///open", + "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" + }, + "headUnitRaw": "HU_MGU", + "headUnitType": "MGU", + "hmiVersion": "ID8", + "lastFetched": "2023-01-04T14:57:06.019Z", + "model": "i4 eDrive40", + "softwareVersionCurrent": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "softwareVersionExFactory": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "telematicsUnit": "WAVE01", + "year": 2021 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "lmmStatusReasons": [], + "mappingStatus": "CONFIRMED" + }, + "vin": "WBA00000000DEMO02" + } +] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json new file mode 100644 index 00000000000..8a0be88edfe --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json @@ -0,0 +1,313 @@ +{ + "capabilities": { + "a4aType": "NOT_SUPPORTED", + "checkSustainabilityDPP": false, + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "digitalKey": { + "bookedServicePackage": "SMACC_1_5", + "readerGraphics": "readerGraphics", + "state": "ACTIVATED" + }, + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": true, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": true, + "isChargingLoudnessEnabled": true, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": true, + "isChargingSettingsEnabled": true, + "isChargingTargetSocEnabled": true, + "isClimateTimerWeeklyActive": false, + "isCustomerEsimSupported": true, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": true, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isPersonalPictureUploadSupported": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": true, + "isSustainabilityAccumulatedViewEnabled": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remote360": true, + "remoteChargingCommands": {}, + "remoteSoftwareUpgrade": true, + "sendPoi": true, + "specialThemeSupport": [], + "speechThirdPartyAlexa": false, + "speechThirdPartyAlexaSDK": false, + "unlock": true, + "vehicleFinder": true, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "IMMEDIATE_CHARGING", + "chargingPreference": "NO_PRESELECTION", + "chargingSettings": { + "acCurrentLimit": 16, + "hospitality": "NO_ACTION", + "idcc": "UNLIMITED_LOUD", + "targetSoc": 80 + }, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + } + ] + }, + "checkControlMessages": [ + { + "severity": "LOW", + "type": "TIRE_PRESSURE" + } + ], + "climateControlState": { + "activity": "STANDBY" + }, + "climateTimers": [ + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": false, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + } + ], + "combustionFuelLevel": {}, + "currentMileage": 1121, + "doorsState": { + "combinedSecurityState": "LOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "UNKNOWN", + "chargingLevelPercent": 80, + "chargingStatus": "INVALID", + "chargingTarget": 80, + "isChargerConnected": false, + "range": 472, + "remainingChargingMinutes": 10 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2023-01-04T14:57:06.386Z", + "lastUpdatedAt": "2023-01-04T14:57:06.407Z", + "location": { + "address": { + "formatted": "Am Olympiapark 1, 80809 München" + }, + "coordinates": { + "latitude": 48.177334, + "longitude": 11.556274 + }, + "heading": 180 + }, + "range": 472, + "requiredServices": [ + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_TUV" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "status": "OK", + "type": "TIRE_WEAR_REAR" + }, + { + "status": "OK", + "type": "TIRE_WEAR_FRONT" + } + ], + "tireState": { + "frontLeft": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 4021, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 241, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "frontRight": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 2419, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 255, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "rearLeft": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 324, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + }, + "rearRight": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 331, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + } + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 349706f593d..7ee3f625911 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -2,6 +2,866 @@ # name: test_config_entry_diagnostics dict({ 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + 'ac_current_limit': 16, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': 'WAVE_01', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 1, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 2, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), + ]), + }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'STANDBY', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_REAR', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_FRONT', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'LOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': '2022-07-10T11:10:00+00:00', + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': 'NOT_CHARGING', + 'charging_target': 80, + 'is_charger_connected': False, + 'remaining_battery_percent': 80, + 'remaining_fuel': list([ + None, + None, + ]), + 'remaining_fuel_percent': None, + 'remaining_range_electric': list([ + 472, + 'km', + ]), + 'remaining_range_fuel': list([ + None, + None, + ]), + 'remaining_range_total': list([ + 472, + 'km', + ]), + }), + 'has_combustion_drivetrain': False, + '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_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': True, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': True, + 'is_remote_set_target_soc_enabled': True, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': True, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 1121, + 'km', + ]), + 'name': 'i4 eDrive40', + 'timestamp': '2023-01-04T14:57:06+00:00', + 'tires': dict({ + 'front_left': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'front_right': dict({ + 'current_pressure': 255, + 'manufacturing_week': '2019-06-10T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'rear_left': dict({ + 'current_pressure': 324, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + 'rear_right': dict({ + 'current_pressure': 331, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + }), + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'remote_service_position': None, + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', + }), + 'vin': '**REDACTED**', + }), dict({ 'available_attributes': list([ 'gps_position', @@ -151,6 +1011,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -648,6 +1524,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, @@ -661,6 +1538,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -714,6 +1640,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -933,7 +2288,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -998,7 +2353,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ @@ -1160,6 +2515,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -1657,6 +3028,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, @@ -1669,6 +3041,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -1722,6 +3143,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -1941,7 +3791,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2006,7 +3856,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ @@ -2023,6 +3873,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -2076,6 +3975,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -2295,7 +4623,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2360,7 +4688,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr new file mode 100644 index 00000000000..a99d8bb3e0f --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr new file mode 100644 index 00000000000..522e74c61e2 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'icon': 'mdi:current-ac', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'last_changed': , + 'last_updated': , + 'state': '16', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'DELAYED_CHARGING', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py new file mode 100644 index 00000000000..b6c16af3e03 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_number.py @@ -0,0 +1,123 @@ +"""Test BMW numbers.""" +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.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 number options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all number entities + assert hass.states.async_all("number") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "80"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 + + +@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 number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "number", + "set_value", + service_data={"value": "80"}, + blocking=True, + target={"entity_id": "number.i4_edrive40_target_soc"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py new file mode 100644 index 00000000000..bbef62b14ed --- /dev/null +++ b/tests/components/bmw_connected_drive/test_select.py @@ -0,0 +1,82 @@ +"""Test BMW selects.""" +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test select options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("select") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), + ("select.i4_edrive40_ac_charging_limit", "16"), + ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i4_edrive40_ac_charging_limit", "17"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index f14efcdf172..9b2e82abc84 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -127,7 +127,12 @@ def patch_bond_version( return nullcontext() if return_value is None: - return_value = {"bondid": "ZXXX12345"} + return_value = { + "bondid": "ZXXX12345", + "target": "test-model", + "fw_ver": "test-version", + "mcu_ver": "test-hw-version", + } return patch( "homeassistant.components.bond.Bond.version", diff --git a/tests/components/bond/test_diagnostics.py b/tests/components/bond/test_diagnostics.py index 238c5c37861..f8d0313ee9c 100644 --- a/tests/components/bond/test_diagnostics.py +++ b/tests/components/bond/test_diagnostics.py @@ -42,5 +42,12 @@ async def test_diagnostics( "data": {"access_token": "**REDACTED**", "host": "some host"}, "title": "Mock Title", }, - "hub": {"version": {"bondid": "ZXXX12345"}}, + "hub": { + "version": { + "bondid": "ZXXX12345", + "fw_ver": "test-version", + "mcu_ver": "test-hw-version", + "target": "test-model", + } + }, } diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index e97fc40beba..f2fa109af22 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -25,8 +25,14 @@ from homeassistant.components.fan import ( SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + FanEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) -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 device_registry as dr, entity_registry as er @@ -211,9 +217,9 @@ async def test_turn_on_fan_preset_mode(hass: HomeAssistant) -> None: bond_device_id="test-device-id", props={"max_speed": 6}, ) - assert hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODES] == [ - PRESET_MODE_BREEZE - ] + state = hass.states.get("fan.name_1") + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_MODE_BREEZE] + assert state.attributes[ATTR_SUPPORTED_FEATURES] & FanEntityFeature.PRESET_MODE with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state(): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) @@ -468,3 +474,63 @@ async def test_fan_available(hass: HomeAssistant) -> None: await help_test_entity_available( hass, FAN_DOMAIN, ceiling_fan("name-1"), "fan.name_1" ) + + +async def test_setup_smart_by_bond_fan(hass: HomeAssistant) -> None: + """Test setting up a fan without a hub.""" + config_entry = await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + bond_version={ + "bondid": "KXXX12345", + "target": "test-model", + "fw_ver": "test-version", + "mcu_ver": "test-hw-version", + }, + ) + assert hass.states.get("fan.name_1") is not None + registry = er.async_get(hass) + entry = registry.async_get("fan.name_1") + assert entry.device_id is not None + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + assert device is not None + assert device.sw_version == "test-version" + assert device.manufacturer == "Olibra" + assert device.identifiers == {("bond", "KXXX12345", "test-device-id")} + assert device.hw_version == "test-hw-version" + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_hub_template_fan(hass: HomeAssistant) -> None: + """Test setting up a fan on a hub created from a template.""" + config_entry = await setup_platform( + hass, + FAN_DOMAIN, + {**ceiling_fan("name-1"), "template": "test-template"}, + bond_device_id="test-device-id", + props={"branding_profile": "test-branding-profile"}, + bond_version={ + "bondid": "ZXXX12345", + "target": "test-model", + "fw_ver": "test-version", + "mcu_ver": "test-hw-version", + }, + ) + assert hass.states.get("fan.name_1") is not None + registry = er.async_get(hass) + entry = registry.async_get("fan.name_1") + assert entry.device_id is not None + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + assert device is not None + assert device.sw_version is None + assert device.model == "test-branding-profile test-template" + assert device.manufacturer == "Olibra" + assert device.identifiers == {("bond", "ZXXX12345", "test-device-id")} + assert device.hw_version is None + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 3dffeaf527c..1ac1fcd4bea 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -94,7 +94,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_ssdp_discovery(hass: HomeAssistant) -> None: diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 0e1db72ddae..629295e09e0 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -26,7 +26,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_create_entry_with_hostname(hass: HomeAssistant) -> None: diff --git a/tests/components/brottsplatskartan/__init__.py b/tests/components/brottsplatskartan/__init__.py new file mode 100644 index 00000000000..4cbc230d240 --- /dev/null +++ b/tests/components/brottsplatskartan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Brottsplatskartan integration.""" diff --git a/tests/components/brottsplatskartan/conftest.py b/tests/components/brottsplatskartan/conftest.py new file mode 100644 index 00000000000..430fec9620b --- /dev/null +++ b/tests/components/brottsplatskartan/conftest.py @@ -0,0 +1,25 @@ +"""Test fixtures for Brottplatskartan.""" +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.brottsplatskartan.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def uuid_generator() -> Generator[AsyncMock, None, None]: + """Generate uuid for app-id.""" + with patch( + "homeassistant.components.brottsplatskartan.config_flow.uuid.getnode", + return_value="1234567890", + ) as uuid_generator: + yield uuid_generator diff --git a/tests/components/brottsplatskartan/test_config_flow.py b/tests/components/brottsplatskartan/test_config_flow.py new file mode 100644 index 00000000000..dd3139dc2b9 --- /dev/null +++ b/tests/components/brottsplatskartan/test_config_flow.py @@ -0,0 +1,209 @@ +"""Test the Brottsplatskartan config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.brottsplatskartan.const import CONF_AREA, DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREA: "none", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan HOME" + assert result2["data"] == { + "area": None, + "latitude": hass.config.latitude, + "longitude": hass.config.longitude, + "app_id": "ha-1234567890", + } + + +async def test_form_location(hass: HomeAssistant) -> None: + """Test we get the form using location.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREA: "none", + CONF_LOCATION: { + CONF_LATITUDE: 59.32, + CONF_LONGITUDE: 18.06, + }, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan 59.32, 18.06" + assert result2["data"] == { + "area": None, + "latitude": 59.32, + "longitude": 18.06, + "app_id": "ha-1234567890", + } + + +async def test_form_area(hass: HomeAssistant) -> None: + """Test we get the form using area.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: { + CONF_LATITUDE: 59.32, + CONF_LONGITUDE: 18.06, + }, + CONF_AREA: "Stockholms län", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan Stockholms län" + assert result2["data"] == { + "latitude": None, + "longitude": None, + "area": "Stockholms län", + "app_id": "ha-1234567890", + } + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", + ): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan HOME" + assert result2["data"] == { + "latitude": hass.config.latitude, + "longitude": hass.config.longitude, + "area": None, + "app_id": "ha-1234567890", + } + + +async def test_import_flow_location_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml with location.""" + + with patch( + "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", + ): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_LATITUDE: 59.32, + CONF_LONGITUDE: 18.06, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan 59.32, 18.06" + assert result2["data"] == { + "latitude": 59.32, + "longitude": 18.06, + "area": None, + "app_id": "ha-1234567890", + } + + +async def test_import_flow_location_area_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml with location and area.""" + + with patch( + "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", + ): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_LATITUDE: 59.32, + CONF_LONGITUDE: 18.06, + CONF_AREA: ["Blekinge län"], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Brottsplatskartan Blekinge län" + assert result2["data"] == { + "latitude": None, + "longitude": None, + "area": "Blekinge län", + "app_id": "ha-1234567890", + } + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + "latitude": hass.config.latitude, + "longitude": hass.config.longitude, + "area": None, + "app_id": "ha-1234567890", + }, + unique_id="bpk-home", + ).add_to_hass(hass) + + with patch( + "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "already_configured" diff --git a/tests/components/brottsplatskartan/test_init.py b/tests/components/brottsplatskartan/test_init.py new file mode 100644 index 00000000000..6205fddc9da --- /dev/null +++ b/tests/components/brottsplatskartan/test_init.py @@ -0,0 +1,38 @@ +"""Test Brottsplatskartan component setup process.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant.components.brottsplatskartan.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "latitude": hass.config.latitude, + "longitude": hass.config.longitude, + "area": None, + "app_id": "ha-1234567890", + }, + title="BPK-HOME", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.bpk_home") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.bpk_home") + assert not state diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index ab8d4237588..bcd6dec14b1 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -31,7 +31,7 @@ async def test_full_user_flow_implementation( ) assert result.get("type") == RESULT_TYPE_FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/bthome/test_device_trigger.py b/tests/components/bthome/test_device_trigger.py new file mode 100644 index 00000000000..348894346bb --- /dev/null +++ b/tests/components/bthome/test_device_trigger.py @@ -0,0 +1,282 @@ +"""Test BTHome BLE events.""" +import pytest + +from homeassistant.components import automation +from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN +from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + async_get as async_get_dev_reg, +) +from homeassistant.setup import async_setup_component + +from . import make_bthome_v2_adv + +from tests.common import ( + MockConfigEntry, + async_capture_events, + async_get_device_automations, + async_mock_service, +) +from tests.components.bluetooth import inject_bluetooth_service_info_bleak + + +@callback +def get_device_id(mac: str) -> tuple[str, str]: + """Get device registry identifier for bthome_ble.""" + return (BLUETOOTH_DOMAIN, mac) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def _async_setup_bthome_device(hass, mac: str): + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def test_event_long_press(hass: HomeAssistant) -> None: + """Make sure that a long press event is fired.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Emit long press event + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3A\x04"), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["address"] == "A4:C1:38:8D:18:B2" + assert events[0].data["event_type"] == "long_press" + assert events[0].data["event_properties"] is None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_event_rotate_dimmer(hass: HomeAssistant) -> None: + """Make sure that a rotate dimmer event is fired.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Emit rotate dimmer 3 steps left event + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3C\x01\x03"), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["address"] == "A4:C1:38:8D:18:B2" + assert events[0].data["event_type"] == "rotate_left" + assert events[0].data["event_properties"] == {"steps": 3} + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_button(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a BTHome BLE sensor.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Emit long press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3A\x04"), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button", + CONF_SUBTYPE: "long_press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_dimmer(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a BTHome BLE sensor.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Emit rotate left with 3 steps event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3C\x01\x03"), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "dimmer", + CONF_SUBTYPE: "rotate_left", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_for_invalid_bthome_ble_device(hass: HomeAssistant) -> None: + """Test that we don't get triggers for an invalid device.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Creates the device in the registry but no events + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x02\xca\x09\x03\xbf\x13"), + ) + + # wait to make sure there are no events + await hass.async_block_till_done() + assert len(events) == 0 + + dev_reg = async_get_dev_reg(hass) + invalid_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "invdevmac")}, + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, invalid_device.id + ) + assert triggers == [] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: + """Test that we don't get triggers when using an invalid device_id.""" + mac = "DE:70:E8:B2:39:0C" + entry = await _async_setup_bthome_device(hass, mac) + + # Emit motion detected event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + ) + + # wait for the event + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + + invalid_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert invalid_device + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, invalid_device.id + ) + assert triggers == [] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: + """Test for motion event trigger firing.""" + mac = "DE:70:E8:B2:39:0C" + entry = await _async_setup_bthome_device(hass, mac) + + # Emit a button event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3A\x03"), + ) + + # # wait for the event + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(mac)}) + device_id = device.id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "button", + CONF_SUBTYPE: "long_press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_button_long_press"}, + }, + }, + ] + }, + ) + + # Emit long press event + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3A\x04"), + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_button_long_press" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py new file mode 100644 index 00000000000..4d6b5adfde7 --- /dev/null +++ b/tests/components/calendar/conftest.py @@ -0,0 +1,11 @@ +"""Test fixtures for calendar sensor platforms.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 9b889777611..d99a50bd286 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -1,6 +1,8 @@ """The tests for calendar recorder.""" from datetime import timedelta +import pytest + from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME @@ -12,9 +14,15 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in calendar.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() @@ -28,7 +36,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index e210bd7ac30..05c7d95d8ad 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -8,7 +8,8 @@ forward exercising the triggers. """ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import AsyncIterator, Callable, Generator +from contextlib import asynccontextmanager import datetime import logging import secrets @@ -22,6 +23,7 @@ import pytest from homeassistant.components import calendar import homeassistant.components.automation as automation from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -53,7 +55,7 @@ TEST_UPDATE_INTERVAL = datetime.timedelta(minutes=7) class FakeSchedule: """Test fixture class for return events in a specific date range.""" - def __init__(self, hass, freezer): + def __init__(self, hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Initiailize FakeSchedule.""" self.hass = hass self.freezer = freezer @@ -62,8 +64,8 @@ class FakeSchedule: def create_event( self, - start: datetime.timedelta, - end: datetime.timedelta, + start: datetime.datetime, + end: datetime.datetime, summary: str | None = None, description: str | None = None, location: str | None = None, @@ -103,7 +105,7 @@ class FakeSchedule: async_fire_time_changed(self.hass, trigger_time) await self.hass.async_block_till_done() - async def fire_until(self, end: datetime.timedelta) -> None: + async def fire_until(self, end: datetime.datetime) -> None: """Simulate the passage of time by firing alarms until the time is reached.""" current_time = dt_util.as_utc(self.freezer()) @@ -120,7 +122,7 @@ class FakeSchedule: @pytest.fixture -def set_time_zone(hass): +def set_time_zone(hass: HomeAssistant) -> None: """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 @@ -128,7 +130,9 @@ def set_time_zone(hass): @pytest.fixture -def fake_schedule(hass, freezer): +def fake_schedule( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> Generator[FakeSchedule, None, None]: """Fixture that tests can use to make fake events.""" # Setup start time for all tests @@ -149,7 +153,10 @@ async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> No await hass.async_block_till_done() -async def create_automation(hass: HomeAssistant, event_type: str, offset=None) -> None: +@asynccontextmanager +async def create_automation( + hass: HomeAssistant, event_type: str, offset=None +) -> AsyncIterator[None]: """Register an automation.""" trigger_data = { "platform": calendar.DOMAIN, @@ -163,6 +170,7 @@ async def create_automation(hass: HomeAssistant, event_type: str, offset=None) - automation.DOMAIN, { automation.DOMAIN: { + "alias": event_type, "trigger": trigger_data, "action": TEST_AUTOMATION_ACTION, "mode": "queued", @@ -171,13 +179,23 @@ async def create_automation(hass: HomeAssistant, event_type: str, offset=None) - ) await hass.async_block_till_done() + yield + + # Disable automation to cleanup lingering timers + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"automation.{event_type}"}, + blocking=True, + ) + @pytest.fixture -def calls(hass: HomeAssistant) -> Callable[[], list]: +def calls(hass: HomeAssistant) -> Callable[[], list[dict[str, Any]]]: """Fixture to return payload data for automation calls.""" service_calls = async_mock_service(hass, "test", "automation") - def get_trigger_data() -> list: + def get_trigger_data() -> list[dict[str, Any]]: return [c.data for c in service_calls] return get_trigger_data @@ -193,18 +211,23 @@ def mock_update_interval() -> Generator[None, None, None]: yield -async def test_event_start_trigger(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_event_start_trigger( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test the a calendar trigger based on start time.""" event_data = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - await create_automation(hass, EVENT_START) - assert len(calls()) == 0 + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + ) - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), - ) assert calls() == [ { "platform": "calendar", @@ -222,59 +245,65 @@ async def test_event_start_trigger(hass: HomeAssistant, calls, fake_schedule) -> ], ) async def test_event_start_trigger_with_offset( - hass: HomeAssistant, calls, fake_schedule, offset_str, offset_delta + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + offset_str, + offset_delta, ) -> None: """Test the a calendar trigger based on start time with an offset.""" event_data = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) - await create_automation(hass, EVENT_START, offset=offset_str) + async with create_automation(hass, EVENT_START, offset=offset_str): + # No calls yet + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta, + ) + assert len(calls()) == 0 - # No calls yet - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta, - ) - assert len(calls()) == 0 - - # Event has started w/ offset - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } - ] + # Event has started w/ offset + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data, + } + ] -async def test_event_end_trigger(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_event_end_trigger( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test the a calendar trigger based on end time.""" event_data = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), ) - await create_automation(hass, EVENT_END) + async with create_automation(hass, EVENT_END): + # Event started, nothing should fire yet + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:10:00+00:00") + ) + assert len(calls()) == 0 - # Event started, nothing should fire yet - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:10:00+00:00") - ) - assert len(calls()) == 0 - - # Event ends - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 12:10:00+00:00") - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data, - } - ] + # Event ends + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 12:10:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_END, + "calendar_event": event_data, + } + ] @pytest.mark.parametrize( @@ -285,50 +314,57 @@ async def test_event_end_trigger(hass: HomeAssistant, calls, fake_schedule) -> N ], ) async def test_event_end_trigger_with_offset( - hass: HomeAssistant, calls, fake_schedule, offset_str, offset_delta + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + offset_str, + offset_delta, ) -> None: """Test the a calendar trigger based on end time with an offset.""" event_data = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) - await create_automation(hass, EVENT_END, offset=offset_str) + async with create_automation(hass, EVENT_END, offset=offset_str): + # No calls yet + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, + ) + assert len(calls()) == 0 - # No calls yet - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, - ) - assert len(calls()) == 0 - - # Event has started w/ offset - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta, - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_END, - "calendar_event": event_data, - } - ] + # Event has started w/ offset + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta, + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_END, + "calendar_event": event_data, + } + ] async def test_calendar_trigger_with_no_events( - hass: HomeAssistant, calls, fake_schedule + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, ) -> None: """Test a calendar trigger setup with no events.""" - await create_automation(hass, EVENT_START) - await create_automation(hass, EVENT_END) - - # No calls, at arbitrary times - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00") - ) + async with create_automation(hass, EVENT_START), create_automation(hass, EVENT_END): + # No calls, at arbitrary times + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00") + ) assert len(calls()) == 0 -async def test_multiple_start_events(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_multiple_start_events( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test that a trigger fires for multiple events.""" event_data1 = fake_schedule.create_event( @@ -339,11 +375,10 @@ async def test_multiple_start_events(hass: HomeAssistant, calls, fake_schedule) start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - await create_automation(hass, EVENT_START) - - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") - ) + async with create_automation(hass, EVENT_START): + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") + ) assert calls() == [ { "platform": "calendar", @@ -358,7 +393,11 @@ async def test_multiple_start_events(hass: HomeAssistant, calls, fake_schedule) ] -async def test_multiple_end_events(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_multiple_end_events( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test that a trigger fires for multiple events.""" event_data1 = fake_schedule.create_event( @@ -369,11 +408,11 @@ async def test_multiple_end_events(hass: HomeAssistant, calls, fake_schedule) -> start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - await create_automation(hass, EVENT_END) + async with create_automation(hass, EVENT_END): + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") + ) - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") - ) assert calls() == [ { "platform": "calendar", @@ -389,7 +428,9 @@ async def test_multiple_end_events(hass: HomeAssistant, calls, fake_schedule) -> async def test_multiple_events_sharing_start_time( - hass: HomeAssistant, calls, fake_schedule + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, ) -> None: """Test that a trigger fires for every event sharing a start time.""" @@ -401,11 +442,11 @@ async def test_multiple_events_sharing_start_time( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - await create_automation(hass, EVENT_START) + async with create_automation(hass, EVENT_START): + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:35:00+00:00") + ) - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:35:00+00:00") - ) assert calls() == [ { "platform": "calendar", @@ -420,7 +461,11 @@ async def test_multiple_events_sharing_start_time( ] -async def test_overlap_events(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_overlap_events( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test that a trigger fires for events that overlap.""" event_data1 = fake_schedule.create_event( @@ -431,11 +476,11 @@ async def test_overlap_events(hass: HomeAssistant, calls, fake_schedule) -> None start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), ) - await create_automation(hass, EVENT_START) + async with create_automation(hass, EVENT_START): + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") + ) - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") - ) assert calls() == [ { "platform": "calendar", @@ -492,31 +537,34 @@ async def test_legacy_entity_type( assert "is not a calendar entity" in caplog.text -async def test_update_next_event(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_update_next_event( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test detection of a new event after initial trigger is setup.""" event_data1 = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - await create_automation(hass, EVENT_START) + async with create_automation(hass, EVENT_START): + # No calls before event start + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00") + ) + assert len(calls()) == 0 - # No calls before event start - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00") - ) - assert len(calls()) == 0 + # Create a new event between now and when the event fires + event_data2 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00"), + ) - # Create a new event between now and when the event fires - event_data2 = fake_schedule.create_event( - start=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), - end=datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00"), - ) - - # Advance past the end of the events - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") - ) + # Advance past the end of the events + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") + ) assert calls() == [ { "platform": "calendar", @@ -531,38 +579,41 @@ async def test_update_next_event(hass: HomeAssistant, calls, fake_schedule) -> N ] -async def test_update_missed(hass: HomeAssistant, calls, fake_schedule) -> None: +async def test_update_missed( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, +) -> None: """Test that new events are missed if they arrive outside the update interval.""" event_data1 = fake_schedule.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - await create_automation(hass, EVENT_START) + async with create_automation(hass, EVENT_START): + # Events are refreshed at t+TEST_UPDATE_INTERVAL minutes. A new event is + # added, but the next update happens after the event is already over. + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 10:38:00+00:00") + ) + assert len(calls()) == 0 - # Events are refreshed at t+TEST_UPDATE_INTERVAL minutes. A new event is - # added, but the next update happens after the event is already over. - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 10:38:00+00:00") - ) - assert len(calls()) == 0 + fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), + ) - fake_schedule.create_event( - start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), - end=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), - ) - - # Only the first event is returned - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00") - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data1, - }, - ] + # Only the first event is returned + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + ] @pytest.mark.parametrize( @@ -619,30 +670,33 @@ async def test_update_missed(hass: HomeAssistant, calls, fake_schedule) -> None: ) async def test_event_payload( hass: HomeAssistant, - calls, - fake_schedule, - set_time_zone, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + set_time_zone: None, create_data, fire_time, payload_data, ) -> None: """Test the fields in the calendar event payload are set.""" fake_schedule.create_event(**create_data) - await create_automation(hass, EVENT_START) - assert len(calls()) == 0 + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 - await fake_schedule.fire_until(fire_time) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": payload_data, - } - ] + await fake_schedule.fire_until(fire_time) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": payload_data, + } + ] async def test_trigger_timestamp_window_edge( - hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + freezer: FrozenDateTimeFactory, ) -> None: """Test that events in the edge of a scan are included.""" freezer.move_to("2022-04-19 11:00:00+00:00") @@ -652,23 +706,26 @@ async def test_trigger_timestamp_window_edge( start=datetime.datetime.fromisoformat("2022-04-19 11:14:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - await create_automation(hass, EVENT_START) - assert len(calls()) == 0 + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event_data, - } - ] + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data, + } + ] async def test_event_start_trigger_dst( - hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + freezer: FrozenDateTimeFactory, ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") @@ -693,26 +750,27 @@ async def test_event_start_trigger_dst( start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), ) - await create_automation(hass, EVENT_START) - assert len(calls()) == 0 + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2023-03-12 05:00:00-08:00"), - ) - assert calls() == [ - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event1_data, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event2_data, - }, - { - "platform": "calendar", - "event": EVENT_START, - "calendar_event": event3_data, - }, - ] + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2023-03-12 05:00:00-08:00"), + ) + + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event1_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event2_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event3_data, + }, + ] diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 65145f9d3be..8953158e423 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -5,11 +5,18 @@ import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .common import WEBRTC_ANSWER +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture(name="mock_camera") async def mock_camera_fixture(hass): """Initialize a demo camera platform.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 79d86e1ef33..8d37eba219a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -245,6 +245,26 @@ async def test_snapshot_service(hass: HomeAssistant, mock_camera) -> None: assert mock_write.mock_calls[0][1][0] == b"Test" +async def test_snapshot_service_not_allowed_path( + hass: HomeAssistant, mock_camera +) -> None: + """Test snapshot service with a not allowed path.""" + mopen = mock_open() + + with patch("homeassistant.components.camera.open", mopen, create=True), patch( + "homeassistant.components.camera.os.makedirs", + ), pytest.raises(HomeAssistantError, match="/test/snapshot.jpg"): + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: "camera.demo_camera", + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + + async def test_websocket_stream_no_source( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera, mock_stream ) -> None: @@ -350,6 +370,7 @@ async def test_websocket_update_orientation_prefs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera ) -> None: """Test updating camera preferences.""" + await async_setup_component(hass, "homeassistant", {}) client = await hass_ws_client(hass) diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index 9230756cec0..df2b8cbe737 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components import camera from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states @@ -20,9 +22,15 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in calendar.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test camera registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} ) @@ -31,7 +39,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 8001411ac71..46a778f5e31 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1888,6 +1888,7 @@ async def test_failed_cast_other_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from internal_url fails.""" + await async_setup_component(hass, "homeassistant", {}) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component( hass, @@ -1911,6 +1912,7 @@ async def test_failed_cast_internal_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from internal_url fails.""" + await async_setup_component(hass, "homeassistant", {}) await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"}, @@ -1939,6 +1941,7 @@ async def test_failed_cast_external_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from external_url fails.""" + await async_setup_component(hass, "homeassistant", {}) await async_process_ha_core_config( hass, {"external_url": "http://example.com:8123"}, @@ -1969,6 +1972,7 @@ async def test_failed_cast_tts_base_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from tts.base_url fails.""" + await async_setup_component(hass, "homeassistant", {}) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component( hass, diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index df9b64631b3..b6acf375f2e 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -29,6 +29,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test climate registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} ) @@ -37,7 +38,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 40809d2759c..7933d8639c1 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import cloud -from homeassistant.components.cloud import const +from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -18,9 +18,11 @@ async def mock_cloud(hass, config=None): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { + const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION, const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, + const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index e16fb63b34a..93d3dc35bc3 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -8,6 +8,13 @@ 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_user_data(): diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 73dd69db447..2a4be7e1645 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -6,10 +6,27 @@ import pytest from homeassistant.components.alexa import errors from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.components.cloud.const import ( + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) +from homeassistant.components.cloud.prefs import CloudPreferences +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_expose_entity, + async_get_entity_settings, +) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, + EntityCategory, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -21,10 +38,22 @@ def cloud_stub(): return Mock(is_logged_in=True, subscription_expired=False) +def expose_new(hass, expose_new): + """Enable exposing new entities to Alexa.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Alexa.""" + async_expose_entity(hass, "cloud.alexa", entity_id, should_expose) + + async def test_alexa_config_expose_entity_prefs( hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -53,54 +82,61 @@ async def test_alexa_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", + ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", + ) - entity_conf = {"should_expose": False} await cloud_prefs.async_update( - alexa_entity_configs={"light.kitchen": entity_conf}, - alexa_default_expose=["light"], alexa_enabled=True, alexa_report_state=False, ) + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() - assert not conf.should_expose("light.kitchen") - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) - - entity_conf["should_expose"] = True + # an entity which is not in the entity registry can be exposed + expose_entity(hass, "light.kitchen", True) assert conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) assert not conf.should_expose(entity_entry3.entity_id) assert not conf.should_expose(entity_entry4.entity_id) + # this has been hidden + assert not conf.should_expose(entity_entry5.entity_id) + # exposed by default + assert conf.should_expose(entity_entry6.entity_id) - entity_conf["should_expose"] = None - assert conf.should_expose("light.kitchen") - # categorized and hidden entities should not be exposed - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) + expose_entity(hass, entity_entry5.entity_id, True) + assert conf.should_expose(entity_entry5.entity_id) + + expose_entity(hass, entity_entry5.entity_id, None) + assert not conf.should_expose(entity_entry5.entity_id) assert "alexa" not in hass.config.components - await cloud_prefs.async_update( - alexa_default_expose=["sensor"], - ) await hass.async_block_till_done() assert "alexa" in hass.config.components - assert not conf.should_expose("light.kitchen") + assert not conf.should_expose(entity_entry5.entity_id) async def test_alexa_config_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + await cloud_prefs.async_update( alexa_report_state=False, ) @@ -134,6 +170,8 @@ async def test_alexa_config_invalidate_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + aioclient_mock.post( "https://example/access_token", json={ @@ -181,10 +219,18 @@ async def test_alexa_config_fail_refresh_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, reject_reason, expected_exception, ) -> None: """Test Alexa config failing to refresh token.""" + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register a fan entity + entity_entry = entity_registry.async_get_or_create( + "fan", "test", "unique", suggested_object_id="test_fan" + ) aioclient_mock.post( "https://example/access_token", @@ -216,7 +262,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.should_report_state is False assert conf.is_reporting_states is False - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") # Enable state reporting await cloud_prefs.async_update(alexa_report_state=True) @@ -227,7 +273,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.is_reporting_states is True # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "on") + hass.states.async_set(entity_entry.entity_id, "on") await hass.async_block_till_done() # Invalidate the token and try to fetch another @@ -240,7 +286,7 @@ async def test_alexa_config_fail_refresh_token( ) # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") await hass.async_block_till_done() # Check state reporting is still wanted in cloud prefs, but disabled for Alexa @@ -292,16 +338,30 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs, cloud_stub + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to updating exposed entities.""" - hass.states.async_set("binary_sensor.door", "on") + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + hass.states.async_set(binary_sensor_entry.entity_id, "on") hass.states.async_set( - "sensor.temp", + sensor_entry.entity_id, "23", {"device_class": "temperature", "unit_of_measurement": "°C"}, ) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set(light_entry.entity_id, "off") await cloud_prefs.async_update( alexa_enabled=True, @@ -311,36 +371,30 @@ async def test_alexa_update_expose_trigger_sync( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert to_update == ["light.kitchen"] + assert to_update == [light_entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] - assert to_remove == ["light.kitchen"] + assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id] + assert to_remove == [light_entry.entity_id] with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update( @@ -350,56 +404,65 @@ async def test_alexa_update_expose_trigger_sync( assert conf._alexa_sync_unsub is None assert to_update == [] - assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + assert to_remove == [ + binary_sensor_entry.entity_id, + sensor_entry.entity_id, + light_entry.entity_id, + ] async def test_alexa_entity_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Alexa config responds to entity registry.""" + # Enable exposing new entities to Alexa + expose_new(hass, True) + await alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ).async_initialize() with patch_sync_helper() as (to_update, to_remove): - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() assert to_update == [] - assert to_remove == ["light.kitchen"] + assert to_remove == [entry.entity_id] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], "old_entity_id": "light.living_room", }, ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == ["light.living_room"] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -411,6 +474,7 @@ async def test_alexa_update_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to reporting state.""" + assert await async_setup_component(hass, "homeassistant", {}) await cloud_prefs.async_update( alexa_report_state=False, ) @@ -450,6 +514,7 @@ async def test_alexa_handle_logout( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to logging out.""" + assert await async_setup_component(hass, "homeassistant", {}) aconf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) @@ -475,3 +540,238 @@ async def test_alexa_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_alexa_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: False + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.unknown") == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_exposed.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_config.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_blocked.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + + +async def test_alexa_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + + cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = None + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + + +async def test_alexa_config_migrate_expose_entity_prefs_default( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + + binary_sensor_supported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_supported", + original_device_class="door", + suggested_object_id="supported", + ) + + binary_sensor_unsupported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + light = entity_registry.async_get_or_create( + "light", + "test", + "unique", + suggested_object_id="light", + ) + + sensor_supported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_supported", + original_device_class="temperature", + suggested_object_id="supported", + ) + + sensor_unsupported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + water_heater = entity_registry.async_get_or_create( + "water_heater", + "test", + "unique", + suggested_object_id="water_heater", + ) + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + + cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = [ + "binary_sensor", + "light", + "sensor", + "water_heater", + ] + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, light.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_supported.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, water_heater.entity_id) == { + "cloud.alexa": {"should_expose": False} + } diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index b7bfed53aac..534456896b4 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -13,8 +13,14 @@ from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, ) +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_expose_entity, +) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -245,15 +251,24 @@ async def test_google_config_expose_entity( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config exposing entity method uses latest config.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() assert gconf.should_expose(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False - ) + async_expose_entity(hass, "cloud.google_assistant", entity_entry.entity_id, False) assert not gconf.should_expose(state) @@ -262,14 +277,21 @@ async def test_google_config_should_2fa( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config disabling 2FA method uses latest config.""" + entity_registry = er.async_get(hass) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client gconf = await cloud_client.get_google_config() - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") assert gconf.should_2fa(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", disable_2fa=True + entity_registry.async_update_entity_options( + entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True} ) assert not gconf.should_2fa(state) @@ -284,7 +306,7 @@ async def test_set_username(hass: HomeAssistant) -> None: ) client = CloudClient(hass, prefs, None, {}, {}) client.cloud = MagicMock(is_logged_in=True, username="mock-username") - await client.cloud_started() + await client.on_cloud_connected() assert len(prefs.async_set_username.mock_calls) == 1 assert prefs.async_set_username.mock_calls[0][1][0] == "mock-username" @@ -304,7 +326,7 @@ async def test_login_recovers_bad_internet( client._alexa_config = Mock( async_enable_proactive_mode=Mock(side_effect=aiohttp.ClientError) ) - await client.cloud_started() + await client.on_cloud_connected() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 1 assert "Unable to activate Alexa Report State" in caplog.text diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 6725fbea633..0fa37ed9987 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -6,11 +6,29 @@ from freezegun import freeze_time import pytest from homeassistant.components.cloud import GACTIONS_SCHEMA +from homeassistant.components.cloud.const import ( + PREF_DISABLE_2FA, + PREF_GOOGLE_DEFAULT_EXPOSE, + PREF_GOOGLE_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) from homeassistant.components.cloud.google_config import CloudGoogleConfig +from homeassistant.components.cloud.prefs import CloudPreferences from homeassistant.components.google_assistant import helpers as ga_helpers -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_expose_entity, + async_get_entity_settings, +) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, + EntityCategory, +) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -28,10 +46,23 @@ def mock_conf(hass, cloud_prefs): ) +def expose_new(hass, expose_new): + """Enable exposing new entities to Google.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Google.""" + async_expose_entity(hass, "cloud.google_assistant", entity_id, should_expose) + + async def test_google_update_report_state( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating preference.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -51,6 +82,8 @@ async def test_google_update_report_state_subscription_expired( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config not reporting state when subscription has expired.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -68,6 +101,8 @@ async def test_google_update_report_state_subscription_expired( async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None: """Test sync devices.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -88,6 +123,22 @@ async def test_google_update_expose_trigger_sync( hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating exposed entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + with freeze_time(utcnow()): config = CloudGoogleConfig( hass, @@ -97,14 +148,14 @@ async def test_google_update_expose_trigger_sync( Mock(claims={"cognito:username": "abcdefghjkl"}), ) await config.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() await config.async_connect_agent_user("mock-user-id") with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -114,15 +165,9 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -134,6 +179,11 @@ async def test_google_entity_registry_sync( hass: HomeAssistant, mock_cloud_login, cloud_prefs ) -> None: """Test Google config responds to entity registry.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) @@ -146,9 +196,8 @@ async def test_google_entity_registry_sync( ga_helpers, "SYNC_DELAY", 0 ): # Created entity - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() @@ -157,7 +206,7 @@ async def test_google_entity_registry_sync( # Removed entity hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -168,7 +217,7 @@ async def test_google_entity_registry_sync( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], }, ) @@ -179,7 +228,7 @@ async def test_google_entity_registry_sync( # Entity registry updated with non-relevant changes hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -189,7 +238,7 @@ async def test_google_entity_registry_sync( hass.state = CoreState.starting hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + {"action": "create", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -204,6 +253,10 @@ async def test_google_device_registry_sync( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) ent_reg = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234") entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") @@ -293,6 +346,7 @@ async def test_google_config_expose_entity_prefs( hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry ) -> None: """Test Google config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -321,45 +375,48 @@ async def test_google_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) - - entity_conf = {"should_expose": False} - await cloud_prefs.async_update( - google_entity_configs={"light.kitchen": entity_conf}, - google_default_expose=["light"], + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", + ) + + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") state_diagnostic = State(entity_entry2.entity_id, "on") state_hidden_integration = State(entity_entry3.entity_id, "on") state_hidden_user = State(entity_entry4.entity_id, "on") + state_not_exposed = State(entity_entry5.entity_id, "on") + state_exposed_default = State(entity_entry6.entity_id, "on") - assert not mock_conf.should_expose(state) - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) - - entity_conf["should_expose"] = True + # an entity which is not in the entity registry can be exposed + expose_entity(hass, "light.kitchen", True) assert mock_conf.should_expose(state) # categorized and hidden entities should not be exposed assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) assert not mock_conf.should_expose(state_hidden_integration) assert not mock_conf.should_expose(state_hidden_user) + # this has been hidden + assert not mock_conf.should_expose(state_not_exposed) + # exposed by default + assert mock_conf.should_expose(state_exposed_default) - entity_conf["should_expose"] = None - assert mock_conf.should_expose(state) - # categorized and hidden entities should not be exposed - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) + expose_entity(hass, entity_entry5.entity_id, True) + assert mock_conf.should_expose(state_not_exposed) - await cloud_prefs.async_update( - google_default_expose=["sensor"], - ) - assert not mock_conf.should_expose(state) + expose_entity(hass, entity_entry5.entity_id, None) + assert not mock_conf.should_expose(state_not_exposed) def test_enabled_requires_valid_sub( @@ -379,6 +436,7 @@ def test_enabled_requires_valid_sub( async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None: """Test that we set up the integration if used.""" + assert await async_setup_component(hass, "homeassistant", {}) mock_conf._cloud.subscription_expired = False assert "google_assistant" not in hass.config.components @@ -423,3 +481,250 @@ async def test_google_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_google_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_no_2fa_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_no_2fa_exposed", + suggested_object_id="no_2fa_exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True, + PREF_DISABLE_2FA: True, + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: False + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_no_2fa_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True, + PREF_DISABLE_2FA: True, + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.unknown") == { + "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} + } + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_exposed.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_no_2fa_exposed.entity_id) == { + "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} + } + assert async_get_entity_settings(hass, entity_config.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_blocked.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + + +async def test_google_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + + cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = None + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + + +async def test_google_config_migrate_expose_entity_prefs_default( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + + binary_sensor_supported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_supported", + original_device_class="door", + suggested_object_id="supported", + ) + + binary_sensor_unsupported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + light = entity_registry.async_get_or_create( + "light", + "test", + "unique", + suggested_object_id="light", + ) + + sensor_supported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_supported", + original_device_class="temperature", + suggested_object_id="supported", + ) + + sensor_unsupported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + water_heater = entity_registry.async_get_or_create( + "water_heater", + "test", + "unique", + suggested_object_id="water_heater", + ) + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + + cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = [ + "binary_sensor", + "light", + "sensor", + "water_heater", + ] + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, light.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_supported.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, water_heater.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 92c0ca70a17..ff79fd1ea77 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,7 +14,10 @@ from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity +from homeassistant.components.homeassistant import exposed_entities from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util.location import LocationInfo from . import mock_cloud, mock_cloud_prefs @@ -103,16 +106,74 @@ async def test_google_actions_sync_fails( async def test_login_view(hass: HomeAssistant, cloud_client) -> None: - """Test logging in.""" + """Test logging in when an assist pipeline is available.""" hass.data["cloud"] = MagicMock(login=AsyncMock()) + await async_setup_component(hass, "stt", {}) + await async_setup_component(hass, "tts", {}) - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + with patch( + "homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines", + return_value=[ + Mock( + conversation_engine="homeassistant", + id="12345", + stt_engine=DOMAIN, + tts_engine=DOMAIN, + ) + ], + ), patch( + "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.OK result = await req.json() - assert result == {"success": True} + assert result == {"success": True, "cloud_pipeline": None} + create_pipeline_mock.assert_not_awaited() + + +async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None: + """Test logging in when no assist pipeline is available.""" + hass.data["cloud"] = MagicMock(login=AsyncMock()) + await async_setup_component(hass, "stt", {}) + await async_setup_component(hass, "tts", {}) + + with patch( + "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + return_value=AsyncMock(id="12345"), + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": "12345"} + create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + + +async def test_login_view_create_pipeline_fail( + hass: HomeAssistant, cloud_client +) -> None: + """Test logging in when no assist pipeline is available.""" + hass.data["cloud"] = MagicMock(login=AsyncMock()) + await async_setup_component(hass, "stt", {}) + await async_setup_component(hass, "tts", {}) + + with patch( + "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + return_value=None, + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": None} + create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") async def test_login_view_random_exception(cloud_client) -> None: @@ -399,11 +460,9 @@ async def test_websocket_status( "alexa_enabled": True, "cloudhooks": {}, "google_enabled": True, - "google_entity_configs": {}, "google_secure_devices_pin": None, "google_default_expose": None, "alexa_default_expose": None, - "alexa_entity_configs": {}, "alexa_report_state": True, "google_report_state": True, "remote_enabled": False, @@ -430,6 +489,7 @@ async def test_websocket_status( "google_local_connected": False, "remote_domain": None, "remote_connected": False, + "remote_certificate_status": None, "remote_certificate": None, "http_use_ssl": False, "active_subscription": False, @@ -520,8 +580,6 @@ async def test_websocket_update_preferences( "alexa_enabled": False, "google_enabled": False, "google_secure_devices_pin": "1234", - "google_default_expose": ["light", "switch"], - "alexa_default_expose": ["sensor", "media_player"], "tts_default_voice": ["en-GB", "male"], } ) @@ -531,8 +589,6 @@ async def test_websocket_update_preferences( assert not setup_api.google_enabled assert not setup_api.alexa_enabled assert setup_api.google_secure_devices_pin == "1234" - assert setup_api.google_default_expose == ["light", "switch"] - assert setup_api.alexa_default_expose == ["sensor", "media_player"] assert setup_api.tts_default_voice == ("en-GB", "male") @@ -683,7 +739,11 @@ async def test_enabling_remote( async def test_list_google_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -699,9 +759,35 @@ async def test_list_google_entities( "homeassistant.components.google_assistant.helpers.async_get_entities", return_value=[entity, entity2], ): - await client.send_json({"id": 5, "type": "cloud/google_assistant/entities"}) + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 2 + assert response["result"][0] == { + "entity_id": "light.kitchen", + "might_2fa": False, + "traits": ["action.devices.traits.OnOff"], + } + assert response["result"][1] == { + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + # Add the entities to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "cover", "test", "unique", suggested_object_id="garage" + ) + + with patch( + "homeassistant.components.google_assistant.helpers.async_get_entities", + return_value=[entity, entity2], + ): + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -716,49 +802,137 @@ async def test_list_google_entities( } +async def test_get_google_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, +) -> None: + """Test that we can get a Google entity.""" + client = await hass_ws_client(hass) + + # Test getting an unknown entity + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "light.kitchen unknown", + } + + # Test getting a blocked entity + entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "group.all_locks not supported by Google assistant", + } + + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("cover.garage", "open", {"device_class": "garage"}) + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "disable_2fa": None, + "entity_id": "light.kitchen", + "might_2fa": False, + "traits": ["action.devices.traits.OnOff"], + } + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "disable_2fa": None, + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + + # Set the disable 2fa flag + await client.send_json_auto_id( + { + "type": "cloud/google_assistant/entities/update", + "entity_id": "cover.garage", + "disable_2fa": True, + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "disable_2fa": True, + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + + async def test_update_google_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of a Google entity.""" client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "cloud/google_assistant/entities/update", "entity_id": "light.kitchen", - "should_expose": False, "disable_2fa": False, } ) response = await client.receive_json() - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.google_entity_configs["light.kitchen"] == { - "should_expose": False, - "disable_2fa": False, - } - await client.send_json( + await client.send_json_auto_id( { - "id": 6, - "type": "cloud/google_assistant/entities/update", - "entity_id": "light.kitchen", - "should_expose": None, + "type": "homeassistant/expose_entity", + "assistants": ["cloud.google_assistant"], + "entity_ids": ["light.kitchen"], + "should_expose": False, } ) response = await client.receive_json() - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.google_entity_configs["light.kitchen"] == { - "should_expose": None, - "disable_2fa": False, + + assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == { + "cloud.google_assistant": {"disable_2fa": False, "should_expose": False} } async def test_list_alexa_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -769,9 +943,27 @@ async def test_list_alexa_entities( "homeassistant.components.alexa.entities.async_get_entities", return_value=[entity], ): - await client.send_json({"id": 5, "type": "cloud/alexa/entities"}) + await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 1 + assert response["result"][0] == { + "entity_id": "light.kitchen", + "display_categories": ["LIGHT"], + "interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"], + } + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + with patch( + "homeassistant.components.alexa.entities.async_get_entities", + return_value=[entity], + ): + await client.send_json_auto_id({"type": "cloud/alexa/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -781,38 +973,101 @@ async def test_list_alexa_entities( } +async def test_get_alexa_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, +) -> None: + """Test that we can get an Alexa entity.""" + client = await hass_ws_client(hass) + + # Test getting an unknown entity + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + # Test getting an unknown sensor + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "sensor.temperature not supported by Alexa", + } + + # Test getting a blocked entity + entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "group.all_locks not supported by Alexa", + } + + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "water_heater", "test", "unique", suggested_object_id="basement" + ) + + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "water_heater.basement not supported by Alexa", + } + + async def test_update_alexa_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of an Alexa entity.""" + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, - "type": "cloud/alexa/entities/update", - "entity_id": "light.kitchen", + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry.entity_id], "should_expose": False, } ) response = await client.receive_json() assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False} - - await client.send_json( - { - "id": 6, - "type": "cloud/alexa/entities/update", - "entity_id": "light.kitchen", - "should_expose": None, - } - ) - response = await client.receive_json() - - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None} + assert exposed_entities.async_get_entity_settings(hass, entry.entity_id) == { + "cloud.alexa": {"should_expose": False} + } async def test_sync_alexa_entities_timeout( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index b082fba25e8..bd0a4972241 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -2,6 +2,7 @@ from typing import Any from unittest.mock import patch +from hass_nabucasa import Cloud import pytest from homeassistant.components import cloud @@ -134,9 +135,9 @@ async def test_setup_existing_cloud_user( async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: """Test cloud on connect triggers.""" - cl = hass.data["cloud"] + cl: Cloud = hass.data["cloud"] - assert len(cl.iot._on_connect) == 3 + assert len(cl.iot._on_connect) == 4 assert len(hass.states.async_entity_ids("binary_sensor")) == 0 @@ -152,10 +153,17 @@ async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: await cl.iot._on_connect[-1]() await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("binary_sensor")) == 0 + + # The on_start callback discovers the binary sensor platform + assert "async_setup" in str(cl._on_start[-1]) + await cl._on_start[-1]() + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("binary_sensor")) == 1 with patch("homeassistant.helpers.discovery.async_load_platform") as mock_load: - await cl.iot._on_connect[-1]() + await cl._on_start[-1]() await hass.async_block_till_done() assert len(mock_load.mock_calls) == 0 @@ -163,12 +171,22 @@ async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: assert len(cloud_states) == 1 assert cloud_states[-1] == cloud.CloudConnectionState.CLOUD_CONNECTED + await cl.iot._on_connect[-1]() + await hass.async_block_till_done() + assert len(cloud_states) == 2 + assert cloud_states[-1] == cloud.CloudConnectionState.CLOUD_CONNECTED + assert len(cl.iot._on_disconnect) == 2 assert "async_setup" in str(cl.iot._on_disconnect[-1]) await cl.iot._on_disconnect[-1]() await hass.async_block_till_done() - assert len(cloud_states) == 2 + assert len(cloud_states) == 3 + assert cloud_states[-1] == cloud.CloudConnectionState.CLOUD_DISCONNECTED + + await cl.iot._on_disconnect[-1]() + await hass.async_block_till_done() + assert len(cloud_states) == 4 assert cloud_states[-1] == cloud.CloudConnectionState.CLOUD_DISCONNECTED diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 96b87936da4..79e45e9ba26 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import Mock from aiohttp import ClientError +from hass_nabucasa.remote import CertificateStatus from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -32,7 +33,11 @@ async def test_cloud_system_health( relayer_server="cloud.bla.com", acme_server="cert-server", is_logged_in=True, - remote=Mock(is_connected=False, snitun_server="us-west-1"), + remote=Mock( + is_connected=False, + snitun_server="us-west-1", + certificate_status=CertificateStatus.READY, + ), expiration_date=now, is_connected=True, client=Mock( @@ -54,6 +59,7 @@ async def test_cloud_system_health( assert info == { "logged_in": True, "subscription_expiration": now, + "certificate_status": "ready", "relayer_connected": True, "relayer_region": "xx-earth-616", "remote_enabled": True, diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index a17b0ae2f08..ba88ae2af2d 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -48,8 +48,9 @@ async def test_prefs_default_voice( """Test cloud provider uses the preferences.""" assert cloud_prefs.tts_default_voice == ("en-US", "female") + tts_info = {"platform_loaded": Mock()} provider_pref = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, {} + Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info ) provider_conf = await tts.async_get_engine( Mock(data={const.DOMAIN: cloud_with_prefs}), @@ -58,32 +59,37 @@ async def test_prefs_default_voice( ) assert provider_pref.default_language == "en-US" - assert provider_pref.default_options == {"gender": "female"} + assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"} assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female"} + assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male")) await hass.async_block_till_done() assert provider_pref.default_language == "nl-NL" - assert provider_pref.default_options == {"gender": "male"} + assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"} assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female"} + assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} async def test_provider_properties(cloud_with_prefs) -> None: """Test cloud provider.""" + tts_info = {"platform_loaded": Mock()} provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, {} + Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info ) - assert provider.supported_options == ["gender"] + assert provider.supported_options == ["gender", "voice", "audio_output"] assert "nl-NL" in provider.supported_languages + assert tts.Voice( + "ColetteNeural", "ColetteNeural" + ) in provider.async_get_supported_voices("nl-NL") async def test_get_tts_audio(cloud_with_prefs) -> None: """Test cloud provider.""" + tts_info = {"platform_loaded": Mock()} provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, {} + Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info ) - assert provider.supported_options == ["gender"] + assert provider.supported_options == ["gender", "voice", "audio_output"] assert "nl-NL" in provider.supported_languages diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index a928be477fb..b1236af89fb 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -32,6 +32,12 @@ LIGHT_ENTITY = "light.kitchen_lights" CLOSE_THRESHOLD = 10 +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def _close_enough(actual_rgb, testing_rgb): """Validate the given RGB value is in acceptable tolerance.""" # Convert the given RGB values to hue / saturation and then back again diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index fac9ae95e61..df57c78c9aa 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -1,18 +1,38 @@ """Tests for the conversation component.""" from __future__ import annotations +from typing import Literal + from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_expose_entity, +) from homeassistant.helpers import intent class MockAgent(conversation.AbstractConversationAgent): """Test Agent.""" - def __init__(self, agent_id: str) -> None: + def __init__( + self, agent_id: str, supported_languages: list[str] | Literal["*"] + ) -> None: """Initialize the agent.""" self.agent_id = agent_id self.calls = [] self.response = "Test response" + self._supported_languages = supported_languages + + @property + def attribution(self) -> conversation.Attribution | None: + """Return the attribution.""" + return {"name": "Mock assistant", "url": "https://assist.me"} + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._supported_languages async def async_process( self, user_input: conversation.ConversationInput @@ -24,3 +44,14 @@ class MockAgent(conversation.AbstractConversationAgent): return conversation.ConversationResult( response=response, conversation_id=user_input.conversation_id ) + + +def expose_new(hass, expose_new): + """Enable exposing new entities to the default agent.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(conversation.DOMAIN, expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to the default agent.""" + async_expose_entity(hass, conversation.DOMAIN, entity_id, should_expose) diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 46f57dbcab9..85d5b5daa91 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components import conversation +from homeassistant.const import MATCH_ALL from . import MockAgent @@ -14,6 +15,16 @@ def mock_agent(hass): """Mock agent.""" entry = MockConfigEntry(entry_id="mock-entry") entry.add_to_hass(hass) - agent = MockAgent(entry.entry_id) + agent = MockAgent(entry.entry_id, ["smurfish"]) + conversation.async_set_agent(hass, entry, agent) + return agent + + +@pytest.fixture +def mock_agent_support_all(hass): + """Mock agent that supports all languages.""" + entry = MockConfigEntry(entry_id="mock-entry-support-all") + entry.add_to_hass(hass) + agent = MockAgent(entry.entry_id, MATCH_ALL) conversation.async_set_agent(hass, entry, agent) return agent diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000..7284b83cb77 --- /dev/null +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -0,0 +1,250 @@ +# serializer version: 1 +# name: test_get_agent_info + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_info.1 + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }) +# --- +# name: test_get_agent_info.2 + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_info.3 + dict({ + 'id': 'mock-entry', + 'name': 'test', + }) +# --- +# name: test_get_agent_list + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'ar', + 'bg', + 'bn', + 'ca', + 'cs', + 'da', + 'de', + 'de-CH', + 'el', + 'en', + 'es', + 'fa', + 'fi', + 'fr', + 'fr-CA', + 'gl', + 'gu', + 'he', + 'hi', + 'hr', + 'hu', + 'id', + 'is', + 'it', + 'ka', + 'kn', + 'lb', + 'lt', + 'lv', + 'ml', + 'mn', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'pt-br', + 'ro', + 'ru', + 'sk', + 'sl', + 'sr', + 'sv', + 'sw', + 'te', + 'tr', + 'uk', + 'ur', + 'vi', + 'zh-cn', + 'zh-hk', + 'zh-tw', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + 'smurfish', + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.1 + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + 'smurfish', + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.2 + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'en', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.3 + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'en', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.4 + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'de', + 'de-CH', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.5 + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'de-CH', + 'de', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_ws_get_agent_info + dict({ + 'attribution': dict({ + 'name': 'Mock assistant', + 'url': 'https://assist.me', + }), + }) +# --- +# name: test_ws_get_agent_info.1 + dict({ + 'attribution': None, + }) +# --- +# name: test_ws_get_agent_info.2 + dict({ + 'attribution': dict({ + 'name': 'Mock assistant', + 'url': 'https://assist.me', + }), + }) +# --- +# name: test_ws_get_agent_info.3 + dict({ + 'code': 'invalid_format', + 'message': "invalid agent ID for dictionary value @ data['agent_id']. Got 'not_exist'", + }) +# --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 338d840c4a7..58fe9371e11 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -4,6 +4,9 @@ from unittest.mock import patch import pytest from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, +) from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant from homeassistant.helpers import ( @@ -15,6 +18,8 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component +from . import expose_entity + from tests.common import async_mock_service @@ -102,27 +107,119 @@ async def test_exposed_areas( bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} ) - def is_entity_exposed(state): - return state.entity_id != bedroom_light.entity_id + # Hide the bedroom light + expose_entity(hass, bedroom_light.entity_id, False) + result = await conversation.async_converse( + hass, "turn on lights in the kitchen", None, Context(), None + ) + + # All is well for the exposed kitchen light + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Bedroom is not exposed because it has no exposed entities + result = await conversation.async_converse( + hass, "turn on lights in the bedroom", None, Context(), None + ) + + # This should be an intent match failure because the area isn't in the slot list + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + + +async def test_conversation_agent( + hass: HomeAssistant, + init_components, +) -> None: + """Test DefaultAgent.""" + agent = await conversation._get_agent_manager(hass).async_get_agent( + conversation.HOME_ASSISTANT_AGENT + ) with patch( - "homeassistant.components.conversation.default_agent.is_entity_exposed", - is_entity_exposed, + "homeassistant.components.conversation.default_agent.get_domains_and_languages", + return_value={"homeassistant": ["dwarvish", "elvish", "entish"]}, ): - result = await conversation.async_converse( - hass, "turn on lights in the kitchen", None, Context(), None - ) + assert agent.supported_languages == ["dwarvish", "elvish", "entish"] - # All is well for the exposed kitchen light - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - # Bedroom is not exposed because it has no exposed entities - result = await conversation.async_converse( - hass, "turn on lights in the bedroom", None, Context(), None - ) +async def test_expose_flag_automatically_set( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test DefaultAgent sets the expose flag on all entities automatically.""" + assert await async_setup_component(hass, "homeassistant", {}) - # This should be an intent match failure because the area isn't in the slot list - assert result.response.response_type == intent.IntentResponseType.ERROR - assert ( - result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH - ) + light = entity_registry.async_get_or_create("light", "demo", "1234") + test = entity_registry.async_get_or_create("test", "demo", "1234") + + assert async_get_assistant_settings(hass, conversation.DOMAIN) == {} + + assert await async_setup_component(hass, "conversation", {}) + await hass.async_block_till_done() + with patch("homeassistant.components.http.start_http_server_and_save_config"): + await hass.async_start() + + # After setting up conversation, the expose flag should now be set on all entities + assert async_get_assistant_settings(hass, conversation.DOMAIN) == { + light.entity_id: {"should_expose": True}, + test.entity_id: {"should_expose": False}, + } + + # New entities will automatically have the expose flag set + new_light = "light.demo_2345" + hass.states.async_set(new_light, "test") + await hass.async_block_till_done() + assert async_get_assistant_settings(hass, conversation.DOMAIN) == { + light.entity_id: {"should_expose": True}, + new_light: {"should_expose": True}, + test.entity_id: {"should_expose": False}, + } + + +async def test_unexposed_entities_skipped( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unexposed entities are skipped in exposed areas.""" + area_kitchen = area_registry.async_get_or_create("kitchen") + + # Both lights are in the kitchen + exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") + entity_registry.async_update_entity( + exposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(exposed_light.entity_id, "off") + + unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678") + entity_registry.async_update_entity( + unexposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(unexposed_light.entity_id, "off") + + # On light is exposed, the other is not + expose_entity(hass, exposed_light.entity_id, True) + expose_entity(hass, unexposed_light.entity_id, False) + + # Only one light should be turned on + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen lights", None, Context(), None + ) + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Only one light should be returned + hass.states.async_set(exposed_light.entity_id, "on") + hass.states.async_set(unexposed_light.entity_id, "on") + result = await conversation.async_converse( + hass, "how many lights are on in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == exposed_light.entity_id diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 9b4348fa599..f13d3cda3e1 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -19,10 +20,12 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component +from . import expose_entity, expose_new + from tests.common import MockConfigEntry, MockUser, async_mock_service from tests.typing import ClientSessionGenerator, WebSocketGenerator -AGENT_ID_OPTIONS = [None, conversation.AgentManager.HOME_ASSISTANT_AGENT] +AGENT_ID_OPTIONS = [None, conversation.HOME_ASSISTANT_AGENT] class OrderBeerIntentHandler(intent.IntentHandler): @@ -267,7 +270,7 @@ async def test_http_processing_intent_entity_added_removed( } # Now delete the entity - entity_registry.async_remove("light.late") + hass.states.async_remove("light.late") client = await hass_client() resp = await client.post( @@ -575,6 +578,298 @@ async def test_http_processing_intent_entity_renamed( } +async def test_http_processing_intent_entity_exposed( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test processing intent via HTTP API with manual expose. + + We want to ensure that manually exposing an entity later busts the cache + so that the new setting is used. + """ + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + entity = platform.MockLight("kitchen light", "on") + entity._attr_unique_id = "1234" + entity.entity_id = "light.kitchen" + platform.ENTITIES.append(entity) + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + await hass.async_block_till_done() + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on kitchen light"} + ) + + assert resp.status == HTTPStatus.OK + assert len(calls) == 1 + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on my cool light"} + ) + + assert resp.status == HTTPStatus.OK + assert len(calls) == 1 + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + # Unexpose the entity + expose_entity(hass, "light.kitchen", False) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on kitchen light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "conversation_id": None, + "response": { + "card": {}, + "data": {"code": "no_intent_match"}, + "language": hass.config.language, + "response_type": "error", + "speech": { + "plain": { + "extra_data": None, + "speech": "Sorry, I couldn't understand that", + } + }, + }, + } + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on my cool light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "conversation_id": None, + "response": { + "card": {}, + "data": {"code": "no_intent_match"}, + "language": hass.config.language, + "response_type": "error", + "speech": { + "plain": { + "extra_data": None, + "speech": "Sorry, I couldn't understand that", + } + }, + }, + } + + # Now expose the entity + expose_entity(hass, "light.kitchen", True) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on kitchen light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on my cool light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + +async def test_http_processing_intent_conversion_not_expose_new( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test processing intent via HTTP API when not exposing new entities.""" + # Disable exposing new entities to the default agent + expose_new(hass, False) + + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + entity = platform.MockLight("kitchen light", "on") + entity._attr_unique_id = "1234" + entity.entity_id = "light.kitchen" + platform.ENTITIES.append(entity) + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + await hass.async_block_till_done() + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + client = await hass_client() + + resp = await client.post( + "/api/conversation/process", json={"text": "turn on kitchen light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "conversation_id": None, + "response": { + "card": {}, + "data": {"code": "no_intent_match"}, + "language": hass.config.language, + "response_type": "error", + "speech": { + "plain": { + "extra_data": None, + "speech": "Sorry, I couldn't understand that", + } + }, + }, + } + + # Expose the entity + expose_entity(hass, "light.kitchen", True) + await hass.async_block_till_done() + + resp = await client.post( + "/api/conversation/process", json={"text": "turn on kitchen light"} + ) + + assert resp.status == HTTPStatus.OK + assert len(calls) == 1 + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) async def test_turn_on_intent( @@ -684,7 +979,9 @@ async def test_http_api_handle_failure( async def test_http_api_unexpected_failure( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, ) -> None: """Test the HTTP conversation API with an unexpected error during handling.""" client = await hass_client() @@ -1207,25 +1504,119 @@ async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): conversation.agent_id_validator("invalid_agent") - conversation.agent_id_validator(conversation.AgentManager.HOME_ASSISTANT_AGENT) + conversation.agent_id_validator(conversation.HOME_ASSISTANT_AGENT) async def test_get_agent_list( - hass: HomeAssistant, init_components, mock_agent, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + init_components, + mock_agent, + mock_agent_support_all, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test getting agent info.""" client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "conversation/agent/list"}) - + await client.send_json_auto_id({"type": "conversation/agent/list"}) msg = await client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "result" assert msg["success"] - assert msg["result"] == { - "agents": [ - {"id": "homeassistant", "name": "Home Assistant"}, - {"id": "mock-entry", "name": "Mock Title"}, - ], - "default_agent": "mock-entry", - } + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "smurfish"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "en"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "en-UK"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "de"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "de", "country": "ch"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + +async def test_get_agent_info( + hass: HomeAssistant, init_components, mock_agent, snapshot: SnapshotAssertion +) -> None: + """Test get agent info.""" + agent_info = conversation.async_get_agent_info(hass) + # Test it's the default + assert agent_info.id == mock_agent.agent_id + assert agent_info == snapshot + assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot + assert conversation.async_get_agent_info(hass, "not exist") is None + + # Test the name when config entry title is empty + agent_entry = hass.config_entries.async_get_entry("mock-entry") + hass.config_entries.async_update_entry(agent_entry, title="") + + agent_info = conversation.async_get_agent_info(hass) + assert agent_info == snapshot + + +async def test_ws_get_agent_info( + hass: HomeAssistant, + init_components, + mock_agent, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test get agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "conversation/agent/info"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/info", "agent_id": "homeassistant"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/info", "agent_id": mock_agent.agent_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/info", "agent_id": "not_exist"} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == snapshot diff --git a/tests/components/coronavirus/__init__.py b/tests/components/coronavirus/__init__.py deleted file mode 100644 index 2274a51506d..00000000000 --- a/tests/components/coronavirus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Coronavirus integration.""" diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py deleted file mode 100644 index 227d9fa2123..00000000000 --- a/tests/components/coronavirus/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Test helpers.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch - -import pytest - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.coronavirus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture(autouse=True) -def mock_cases(): - """Mock coronavirus cases.""" - with patch( - "coronavirus.get_cases", - return_value=[ - Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1), - Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0), - Mock( - country="Sweden", - confirmed=None, - recovered=None, - deaths=None, - current=None, - ), - ], - ) as mock_get_cases: - yield mock_get_cases diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py deleted file mode 100644 index 2fe7ed370e8..00000000000 --- a/tests/components/coronavirus/test_config_flow.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Test the Coronavirus config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch - -from aiohttp import ClientError -import pytest - -from homeassistant import config_entries -from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE -from homeassistant.core import HomeAssistant - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"country": OPTION_WORLDWIDE}, - ) - assert result2["type"] == "create_entry" - assert result2["title"] == "Worldwide" - assert result2["result"].unique_id == OPTION_WORLDWIDE - assert result2["data"] == { - "country": OPTION_WORLDWIDE, - } - await hass.async_block_till_done() - mock_setup_entry.assert_called_once() - - -@patch( - "coronavirus.get_cases", - side_effect=ClientError, -) -async def test_abort_on_connection_error( - mock_get_cases: MagicMock, hass: HomeAssistant -) -> None: - """Test we abort on connection error.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert "type" in result - assert result["type"] == "abort" - assert "reason" in result - assert result["reason"] == "cannot_connect" diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py deleted file mode 100644 index eeb91e77239..00000000000 --- a/tests/components/coronavirus/test_init.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test init of Coronavirus integration.""" -from unittest.mock import MagicMock, patch - -from aiohttp import ClientError - -from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry, mock_registry - - -async def test_migration(hass: HomeAssistant) -> None: - """Test that we can migrate coronavirus to stable unique ID.""" - nl_entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34}) - nl_entry.add_to_hass(hass) - worldwide_entry = MockConfigEntry( - domain=DOMAIN, title="Worldwide", data={"country": OPTION_WORLDWIDE} - ) - worldwide_entry.add_to_hass(hass) - mock_registry( - hass, - { - "sensor.netherlands_confirmed": er.RegistryEntry( - entity_id="sensor.netherlands_confirmed", - unique_id="34-confirmed", - platform="coronavirus", - config_entry_id=nl_entry.entry_id, - ), - "sensor.worldwide_confirmed": er.RegistryEntry( - entity_id="sensor.worldwide_confirmed", - unique_id="__worldwide-confirmed", - platform="coronavirus", - config_entry_id=worldwide_entry.entry_id, - ), - }, - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - ent_reg = er.async_get(hass) - - sensor_nl = ent_reg.async_get("sensor.netherlands_confirmed") - assert sensor_nl.unique_id == "Netherlands-confirmed" - - sensor_worldwide = ent_reg.async_get("sensor.worldwide_confirmed") - assert sensor_worldwide.unique_id == "__worldwide-confirmed" - - assert hass.states.get("sensor.netherlands_confirmed").state == "10" - assert hass.states.get("sensor.worldwide_confirmed").state == "11" - - assert nl_entry.unique_id == "Netherlands" - assert worldwide_entry.unique_id == OPTION_WORLDWIDE - - -@patch( - "coronavirus.get_cases", - side_effect=ClientError, -) -async def test_config_entry_not_ready( - mock_get_cases: MagicMock, hass: HomeAssistant -) -> None: - """Test the configuration entry not ready.""" - entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34}) - entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 84f8c24792b..354e840e548 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -224,9 +224,9 @@ async def test_get_action_capabilities_set_pos( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert len(actions) == 1 # set_position + assert len(actions) == 4 # set_position, open, close, stop action_types = {action["type"] for action in actions} - assert action_types == {"set_position"} + assert action_types == {"set_position", "open", "close", "stop"} for action in actions: capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.ACTION, action @@ -275,9 +275,15 @@ async def test_get_action_capabilities_set_tilt_pos( actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) - assert len(actions) == 3 + assert len(actions) == 5 action_types = {action["type"] for action in actions} - assert action_types == {"open", "close", "set_tilt_position"} + assert action_types == { + "open", + "close", + "set_tilt_position", + "open_tilt", + "close_tilt", + } for action in actions: capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.ACTION, action diff --git a/tests/components/cpuspeed/test_config_flow.py b/tests/components/cpuspeed/test_config_flow.py index 2d0f4c6df22..323eb80d712 100644 --- a/tests/components/cpuspeed/test_config_flow.py +++ b/tests/components/cpuspeed/test_config_flow.py @@ -21,7 +21,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -67,7 +67,7 @@ async def test_not_compatible( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_cpuinfo_config_flow.return_value = {} result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 769a5c16d07..d0c51e39987 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -576,6 +576,7 @@ async def test_flow_hassio_discovery(hass: HomeAssistant) -> None: }, name="Mock Addon", slug="deconz", + uuid="1234", ), context={"source": SOURCE_HASSIO}, ) @@ -628,6 +629,7 @@ async def test_hassio_discovery_update_configuration( }, name="Mock Addon", slug="deconz", + uuid="1234", ), context={"source": SOURCE_HASSIO}, ) @@ -658,6 +660,7 @@ async def test_hassio_discovery_dont_update_configuration( }, name="Mock Addon", slug="deconz", + uuid="1234", ), context={"source": SOURCE_HASSIO}, ) diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index e4c7139cd82..f456508a6f3 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -1,4 +1,5 @@ """Test deCONZ component setup process.""" +import asyncio from unittest.mock import patch from homeassistant.components.deconz import ( @@ -133,6 +134,31 @@ async def test_unload_entry_multiple_gateways( assert hass.data[DECONZ_DOMAIN][config_entry2.entry_id].master +async def test_unload_entry_multiple_gateways_parallel( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test race condition when unloading multiple config entries in parallel.""" + config_entry = await setup_deconz_integration(hass, aioclient_mock) + aioclient_mock.clear_requests() + + data = {"config": {"bridgeid": "01234E56789B"}} + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry2 = await setup_deconz_integration( + hass, + aioclient_mock, + entry_id="2", + unique_id="01234E56789B", + ) + + assert len(hass.data[DECONZ_DOMAIN]) == 2 + + await asyncio.gather( + config_entry.async_unload(hass), config_entry2.async_unload(hass) + ) + + assert len(hass.data[DECONZ_DOMAIN]) == 0 + + async def test_update_group_unique_id(hass: HomeAssistant) -> None: """Test successful migration of entry data.""" old_unique_id = "123" diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index ecbf7394efd..eb27d9f68d4 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -14,7 +14,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + STATE_UNAVAILABLE, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt @@ -106,12 +113,132 @@ TEST_DATA = [ "attributes": { "friendly_name": "BOSCH Air quality sensor PPB", "state_class": "measurement", - "unit_of_measurement": "ppb", + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, }, "websocket_event": {"state": {"airqualityppb": 1000}}, "next_state": "1000", }, ), + ( # Air quality 6 in 1 (without airquality) -> airquality_co2_density + { + "config": { + "on": True, + "reachable": True, + }, + "etag": "e1a406dbbe1438fa924007309ef46a01", + "lastseen": "2023-03-29T18:25Z", + "manufacturername": "_TZE200_dwcarsat", + "modelid": "TS0601", + "name": "AirQuality 1", + "state": { + "airquality_co2_density": 359, + "airquality_formaldehyde_density": 4, + "airqualityppb": 15, + "lastupdated": "2023-03-29T19:05:41.903", + "pm2_5": 8, + }, + "type": "ZHAAirQuality", + "uniqueid": "00:00:00:00:00:00:00:01-02-0113", + }, + { + "entity_count": 4, + "device_count": 3, + "entity_id": "sensor.airquality_1_co2", + "unique_id": "00:00:00:00:00:00:00:01-02-0113-air_quality_co2", + "state": "359", + "entity_category": None, + "device_class": SensorDeviceClass.CO2, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "friendly_name": "AirQuality 1 CO2", + "device_class": SensorDeviceClass.CO2, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + }, + "websocket_event": {"state": {"airquality_co2_density": 332}}, + "next_state": "332", + }, + ), + ( # Air quality 6 in 1 (without airquality) -> airquality_formaldehyde_density + { + "config": { + "on": True, + "reachable": True, + }, + "etag": "e1a406dbbe1438fa924007309ef46a01", + "lastseen": "2023-03-29T18:25Z", + "manufacturername": "_TZE200_dwcarsat", + "modelid": "TS0601", + "name": "AirQuality 1", + "state": { + "airquality_co2_density": 359, + "airquality_formaldehyde_density": 4, + "airqualityppb": 15, + "lastupdated": "2023-03-29T19:05:41.903", + "pm2_5": 8, + }, + "type": "ZHAAirQuality", + "uniqueid": "00:00:00:00:00:00:00:01-02-0113", + }, + { + "entity_count": 4, + "device_count": 3, + "entity_id": "sensor.airquality_1_ch2o", + "unique_id": "00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde", + "state": "4", + "entity_category": None, + "device_class": SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "friendly_name": "AirQuality 1 CH2O", + "device_class": SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "websocket_event": {"state": {"airquality_formaldehyde_density": 5}}, + "next_state": "5", + }, + ), + ( # Air quality 6 in 1 (without airquality) -> pm2_5 + { + "config": { + "on": True, + "reachable": True, + }, + "etag": "e1a406dbbe1438fa924007309ef46a01", + "lastseen": "2023-03-29T18:25Z", + "manufacturername": "_TZE200_dwcarsat", + "modelid": "TS0601", + "name": "AirQuality 1", + "state": { + "airquality_co2_density": 359, + "airquality_formaldehyde_density": 4, + "airqualityppb": 15, + "lastupdated": "2023-03-29T19:05:41.903", + "pm2_5": 8, + }, + "type": "ZHAAirQuality", + "uniqueid": "00:00:00:00:00:00:00:01-02-0113", + }, + { + "entity_count": 4, + "device_count": 3, + "entity_id": "sensor.airquality_1_pm25", + "unique_id": "00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5", + "state": "8", + "entity_category": None, + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "friendly_name": "AirQuality 1 PM25", + "device_class": SensorDeviceClass.PM25, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "websocket_event": {"state": {"pm2_5": 11}}, + "next_state": "11", + }, + ), ( # Battery sensor { "config": { diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index 96032c12018..a6182289a86 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -1,3 +1,14 @@ """demo conftest.""" +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) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/demo/test_stt.py b/tests/components/demo/test_stt.py index e51a07ae4cf..5d4242844ee 100644 --- a/tests/components/demo/test_stt.py +++ b/tests/components/demo/test_stt.py @@ -1,21 +1,40 @@ """The tests for the demo stt component.""" from http import HTTPStatus +from unittest.mock import patch import pytest from homeassistant.components import stt +from homeassistant.components.demo import DOMAIN as DEMO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator -@pytest.fixture(autouse=True) -async def setup_comp(hass): - """Set up demo component.""" +@pytest.fixture +async def setup_legacy_platform(hass: HomeAssistant) -> None: + """Set up legacy demo platform.""" assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "demo"}}) await hass.async_block_till_done() +@pytest.fixture +async def setup_config_entry(hass: HomeAssistant) -> None: + """Set up demo component from config entry.""" + config_entry = MockConfigEntry(domain=DEMO_DOMAIN) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.STT], + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_settings(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() @@ -34,6 +53,7 @@ async def test_demo_settings(hass_client: ClientSessionGenerator) -> None: } +@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech_no_metadata(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() @@ -42,6 +62,7 @@ async def test_demo_speech_no_metadata(hass_client: ClientSessionGenerator) -> N assert response.status == HTTPStatus.BAD_REQUEST +@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech_wrong_metadata(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() @@ -59,6 +80,7 @@ async def test_demo_speech_wrong_metadata(hass_client: ClientSessionGenerator) - assert response.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE +@pytest.mark.usefixtures("setup_legacy_platform") async def test_demo_speech(hass_client: ClientSessionGenerator) -> None: """Test retrieve settings from demo provider.""" client = await hass_client() @@ -77,3 +99,26 @@ async def test_demo_speech(hass_client: ClientSessionGenerator) -> None: assert response.status == HTTPStatus.OK assert response_data == {"text": "Turn the Kitchen Lights on", "result": "success"} + + +@pytest.mark.usefixtures("setup_config_entry") +async def test_config_entry_demo_speech( + hass_client: ClientSessionGenerator, hass: HomeAssistant +) -> None: + """Test retrieve settings from demo provider from config entry.""" + client = await hass_client() + + response = await client.post( + "/api/stt/stt.demo_stt", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=2;" + " language=de" + ) + }, + data=b"Test", + ) + response_data = await response.json() + + assert response.status == HTTPStatus.OK + assert response_data == {"text": "Turn the Kitchen Lights on", "result": "success"} diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d84b64955e1..2fdea0a0bb1 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -17,6 +17,8 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import IntegrationNotFound +from homeassistant.requirements import RequirementsNotFound from homeassistant.setup import async_setup_component from tests.common import ( @@ -1554,3 +1556,25 @@ async def test_automation_with_device_component_not_loaded( ) module.async_validate_trigger_config.assert_not_awaited() + + +@pytest.mark.parametrize( + "exc", + [ + IntegrationNotFound("test"), + RequirementsNotFound("test", []), + ImportError("test"), + ], +) +async def test_async_get_device_automations_platform_reraises_exceptions( + hass: HomeAssistant, exc: Exception +) -> None: + """Test InvalidDeviceAutomationConfig is raised when async_get_integration_with_requirements fails.""" + await async_setup_component(hass, "device_automation", {}) + with patch( + "homeassistant.components.device_automation.async_get_integration_with_requirements", + side_effect=exc, + ), pytest.raises(InvalidDeviceAutomationConfig): + await device_automation.async_get_device_automation_platform( + hass, "test", device_automation.DeviceAutomationType.TRIGGER + ) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 555942941eb..67bc24909c5 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -218,6 +218,7 @@ async def test_discover_platform( mock_demo_setup_scanner, mock_see, hass: HomeAssistant ) -> None: """Test discovery of device_tracker demo platform.""" + await async_setup_component(hass, "homeassistant", {}) with patch("homeassistant.components.device_tracker.legacy.update_config"): await discovery.async_load_platform( hass, device_tracker.DOMAIN, "demo", {"test_key": "test_val"}, {"bla": {}} diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 83e4e21e16f..e07e0b6cfcb 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -1274,6 +1274,7 @@ async def test_unavailable_device( hass.config_entries.async_update_entry( config_entry_mock, options={CONF_POLL_AVAILABILITY: True} ) + await hass.async_block_till_done() await async_update_entity(hass, mock_entity_id) domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( MOCK_DEVICE_LOCATION diff --git a/tests/components/easyenergy/test_config_flow.py b/tests/components/easyenergy/test_config_flow.py index 2ad9b762e83..30d4924db8c 100644 --- a/tests/components/easyenergy/test_config_flow.py +++ b/tests/components/easyenergy/test_config_flow.py @@ -17,7 +17,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" assert "flow_id" in result result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/easyenergy/test_sensor.py b/tests/components/easyenergy/test_sensor.py index edbf5e23ae4..98e94197db9 100644 --- a/tests/components/easyenergy/test_sensor.py +++ b/tests/components/easyenergy/test_sensor.py @@ -124,6 +124,25 @@ async def test_energy_usage_today( assert not device_entry.model assert not device_entry.sw_version + # Usage hours priced equal or lower sensor + state = hass.states.get( + "sensor.easyenergy_today_energy_usage_hours_priced_equal_or_lower" + ) + entry = entity_registry.async_get( + "sensor.easyenergy_today_energy_usage_hours_priced_equal_or_lower" + ) + assert entry + assert state + assert ( + entry.unique_id == f"{entry_id}_today_energy_usage_hours_priced_equal_or_lower" + ) + assert state.state == "21" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Energy market price - Usage Hours priced equal or lower than current - today" + ) + assert ATTR_DEVICE_CLASS not in state.attributes + @pytest.mark.freeze_time("2023-01-19 15:00:00") async def test_energy_return_today( @@ -219,6 +238,26 @@ async def test_energy_return_today( assert not device_entry.model assert not device_entry.sw_version + # Return hours priced equal or higher sensor + state = hass.states.get( + "sensor.easyenergy_today_energy_return_hours_priced_equal_or_higher" + ) + entry = entity_registry.async_get( + "sensor.easyenergy_today_energy_return_hours_priced_equal_or_higher" + ) + assert entry + assert state + assert ( + entry.unique_id + == f"{entry_id}_today_energy_return_hours_priced_equal_or_higher" + ) + assert state.state == "3" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Energy market price - Return Hours priced equal or higher than current - today" + ) + assert ATTR_DEVICE_CLASS not in state.attributes + @pytest.mark.freeze_time("2023-01-19 10:00:00") async def test_gas_today( diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index 6048aea17c0..d01d6163285 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -20,7 +20,7 @@ async def test_bad_credentials(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "pyeconet.EcoNetApiInterface.login", @@ -50,7 +50,7 @@ async def test_generic_error_from_library(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "pyeconet.EcoNetApiInterface.login", @@ -80,7 +80,7 @@ async def test_auth_worked(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "pyeconet.EcoNetApiInterface.login", @@ -117,7 +117,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "pyeconet.EcoNetApiInterface.login", diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 3447038b778..1b71a29632f 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -28,7 +28,7 @@ async def test_full_user_flow_implementation( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 9123} diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index e626a095e14..d2f8d9841d4 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -223,9 +223,25 @@ async def test_no_online_panel(hass: HomeAssistant) -> None: async def test_show_reauth(hass: HomeAssistant) -> None: """Test that the reauth form shows.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH}, + context={ + "source": SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, @@ -239,7 +255,7 @@ async def test_show_reauth(hass: HomeAssistant) -> None: async def test_reauth_flow(hass: HomeAssistant) -> None: """Test that the reauth flow works.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, @@ -248,7 +264,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, }, unique_id=MOCK_PANEL_ID, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) # Trigger reauth with patch( @@ -257,7 +274,11 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: ): reauth_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH}, + context={ + "source": SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, @@ -268,7 +289,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( reauth_result["flow_id"], { - CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, @@ -282,7 +302,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: async def test_reauth_panel_disappeared(hass: HomeAssistant) -> None: """Test that the case where panel is no longer associated with the user.""" # Simulate a first setup - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, @@ -291,7 +311,8 @@ async def test_reauth_panel_disappeared(hass: HomeAssistant) -> None: CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, }, unique_id=MOCK_PANEL_ID, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) # Trigger reauth with patch( @@ -300,7 +321,11 @@ async def test_reauth_panel_disappeared(hass: HomeAssistant) -> None: ): reauth_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH}, + context={ + "source": SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, @@ -311,7 +336,6 @@ async def test_reauth_panel_disappeared(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( reauth_result["flow_id"], { - CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, @@ -324,7 +348,7 @@ async def test_reauth_panel_disappeared(hass: HomeAssistant) -> None: async def test_reauth_invalid_pin(hass: HomeAssistant) -> None: """Test that the case where panel is no longer associated with the user.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, @@ -333,7 +357,8 @@ async def test_reauth_invalid_pin(hass: HomeAssistant) -> None: CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, }, unique_id=MOCK_PANEL_ID, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) # Trigger reauth with patch( @@ -342,7 +367,11 @@ async def test_reauth_invalid_pin(hass: HomeAssistant) -> None: ): reauth_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH}, + context={ + "source": SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, @@ -353,7 +382,6 @@ async def test_reauth_invalid_pin(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( reauth_result["flow_id"], { - CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, @@ -366,7 +394,7 @@ async def test_reauth_invalid_pin(hass: HomeAssistant) -> None: async def test_reauth_bad_login(hass: HomeAssistant) -> None: """Test bad login attempt at reauth time.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, @@ -375,7 +403,8 @@ async def test_reauth_bad_login(hass: HomeAssistant) -> None: CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, }, unique_id=MOCK_PANEL_ID, - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) # Trigger reauth with patch( @@ -384,7 +413,11 @@ async def test_reauth_bad_login(hass: HomeAssistant) -> None: ): reauth_result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH}, + context={ + "source": SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, data={ CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, @@ -395,7 +428,6 @@ async def test_reauth_bad_login(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( reauth_result["flow_id"], { - CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, diff --git a/tests/components/emulated_kasa/test_init.py b/tests/components/emulated_kasa/test_init.py index 9b73957ef71..5d294ec3bba 100644 --- a/tests/components/emulated_kasa/test_init.py +++ b/tests/components/emulated_kasa/test_init.py @@ -2,6 +2,8 @@ import math from unittest.mock import AsyncMock, Mock, patch +import pytest + from homeassistant.components import emulated_kasa from homeassistant.components.emulated_kasa.const import ( CONF_POWER, @@ -132,6 +134,12 @@ CONFIG_SENSOR = { } +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def nested_value(ndict, *keys): """Return a nested dict value or None if it doesn't exist.""" if len(keys) == 0: diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index c24eed9b259..5f7b4925036 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -20,7 +20,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" assert "flow_id" in result result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f53e513e6bb..a70686acbf6 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, DeviceInfo +from aioesphomeapi import APIClient, APIVersion, DeviceInfo import pytest from zeroconf import Zeroconf @@ -101,6 +101,8 @@ def mock_client(mock_device_info): mock_client.device_info = AsyncMock(return_value=mock_device_info) mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.api_version = APIVersion(99, 99) with patch("homeassistant.components.esphome.APIClient", mock_client), patch( "homeassistant.components.esphome.config_flow.APIClient", mock_client @@ -120,3 +122,73 @@ async def mock_dashboard(hass): hass, DASHBOARD_SLUG, DASHBOARD_HOST, DASHBOARD_PORT ) yield data + + +@pytest.fixture +async def mock_voice_assistant_v1_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", + ) + + 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( + 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=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 diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py new file mode 100644 index 00000000000..3f780f3003d --- /dev/null +++ b/tests/components/esphome/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Test ESPHome binary sensors.""" + + +from homeassistant.components.esphome import DomainData +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> None: + """Test assist in progress binary sensor.""" + + entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) + + state = hass.states.get("binary_sensor.test_assist_in_progress") + assert state is not None + assert state.state == "off" + + entry_data.async_set_assist_pipeline_state(True) + + state = hass.states.get("binary_sensor.test_assist_in_progress") + assert state.state == "on" + + entry_data.async_set_assist_pipeline_state(False) + + state = hass.states.get("binary_sensor.test_assist_in_progress") + assert state.state == "off" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f96ecc8327f..7d901733d81 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -813,6 +813,7 @@ async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None: }, name="ESPHome", slug="mock-slug", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/esphome/test_enum_mapper.py b/tests/components/esphome/test_enum_mapper.py new file mode 100644 index 00000000000..52b81bb3836 --- /dev/null +++ b/tests/components/esphome/test_enum_mapper.py @@ -0,0 +1,42 @@ +"""Test ESPHome enum mapper.""" + +from aioesphomeapi import APIIntEnum + +from homeassistant.backports.enum import StrEnum +from homeassistant.components.esphome.enum_mapper import EsphomeEnumMapper + + +class MockEnum(APIIntEnum): + """Mock enum.""" + + ESPHOME_FOO = 1 + ESPHOME_BAR = 2 + + +class MockStrEnum(StrEnum): + """Mock enum.""" + + HA_FOO = "foo" + HA_BAR = "bar" + + +MOCK_MAPPING: EsphomeEnumMapper[MockEnum, MockStrEnum] = EsphomeEnumMapper( + { + MockEnum.ESPHOME_FOO: MockStrEnum.HA_FOO, + MockEnum.ESPHOME_BAR: MockStrEnum.HA_BAR, + } +) + + +async def test_map_esphome_to_ha() -> None: + """Test mapping from ESPHome to HA.""" + + assert MOCK_MAPPING.from_esphome(MockEnum.ESPHOME_FOO) == MockStrEnum.HA_FOO + assert MOCK_MAPPING.from_esphome(MockEnum.ESPHOME_BAR) == MockStrEnum.HA_BAR + + +async def test_map_ha_to_esphome() -> None: + """Test mapping from HA to ESPHome.""" + + assert MOCK_MAPPING.from_hass(MockStrEnum.HA_FOO) == MockEnum.ESPHOME_FOO + assert MOCK_MAPPING.from_hass(MockStrEnum.HA_BAR) == MockEnum.ESPHOME_BAR diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py new file mode 100644 index 00000000000..dec321ced86 --- /dev/null +++ b/tests/components/esphome/test_select.py @@ -0,0 +1,15 @@ +"""Test ESPHome selects.""" + + +from homeassistant.core import HomeAssistant + + +async def test_pipeline_selector( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> None: + """Test assist pipeline selector.""" + + state = hass.states.get("select.test_assist_pipeline") + assert state is not None + assert state.state == "preferred" diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index c8789df2777..5410af96bd7 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,4 +1,5 @@ """Test ESPHome update entities.""" +import asyncio import dataclasses from unittest.mock import Mock, patch @@ -197,3 +198,43 @@ async def test_update_device_state_for_availability( state = hass.states.get("update.none_firmware") assert state.state == "on" + + +async def test_update_entity_dashboard_not_available_startup( + hass: HomeAssistant, mock_config_entry, mock_device_info, mock_dashboard +) -> None: + """Test ESPHome update entity when dashboard is not available at startup.""" + with patch( + "homeassistant.components.esphome.update.DomainData.get_entry_data", + return_value=Mock(available=True, device_info=mock_device_info), + ), patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + side_effect=asyncio.TimeoutError, + ): + await async_get_dashboard(hass).async_refresh() + assert await hass.config_entries.async_forward_entry_setup( + mock_config_entry, "update" + ) + + state = hass.states.get("update.none_firmware") + assert state is None + + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("update.none_firmware") + assert state.state == "on" + expected_attributes = { + "latest_version": "2023.2.0-dev", + "installed_version": "1.0.0", + "supported_features": UpdateEntityFeature.INSTALL, + } + for key, expected_value in expected_attributes.items(): + assert state.attributes.get(key) == expected_value diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py new file mode 100644 index 00000000000..fed83f8ab10 --- /dev/null +++ b/tests/components/esphome/test_voice_assistant.py @@ -0,0 +1,337 @@ +"""Test ESPHome voice assistant server.""" + +import asyncio +import socket +from unittest.mock import Mock, patch + +from aioesphomeapi import VoiceAssistantEventType +import async_timeout +import pytest + +from homeassistant.components import esphome +from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.esphome import DomainData +from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer +from homeassistant.core import HomeAssistant + +_TEST_INPUT_TEXT = "This is an input test" +_TEST_OUTPUT_TEXT = "This is an output test" +_TEST_OUTPUT_URL = "output.mp3" +_TEST_MEDIA_ID = "12345" + + +@pytest.fixture +def voice_assistant_udp_server_v1( + hass: HomeAssistant, + 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 + + +@pytest.fixture +def voice_assistant_udp_server_v2( + hass: HomeAssistant, + 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 + + +async def test_pipeline_events( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test that the pipeline function is called.""" + + async def async_pipeline_from_audio_stream(*args, **kwargs): + event_callback = kwargs["event_callback"] + + # Fake events + event_callback( + PipelineEvent( + type=PipelineEventType.STT_START, + data={}, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": _TEST_INPUT_TEXT}}, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_START, + data={"tts_input": _TEST_OUTPUT_TEXT}, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={"tts_output": {"url": _TEST_OUTPUT_URL}}, + ) + ) + + def handle_event( + event_type: esphome.VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + assert data is not None + assert data["text"] == _TEST_INPUT_TEXT + elif event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + assert data is not None + assert data["text"] == _TEST_OUTPUT_TEXT + elif event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert data is not None + assert data["url"] == _TEST_OUTPUT_URL + + voice_assistant_udp_server_v1.handle_event = handle_event + + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + voice_assistant_udp_server_v1.transport = Mock() + + await voice_assistant_udp_server_v1.run_pipeline() + + +async def test_udp_server( + hass: HomeAssistant, + socket_enabled, + unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server runs and queues incoming data.""" + port_to_use = unused_udp_port_factory() + + with patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=port_to_use + ): + port = await voice_assistant_udp_server_v1.start_server() + assert port == port_to_use + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + assert voice_assistant_udp_server_v1.queue.qsize() == 0 + sock.sendto(b"test", ("127.0.0.1", port)) + + # Give the socket some time to send/receive the data + async with async_timeout.timeout(1): + while voice_assistant_udp_server_v1.queue.qsize() == 0: + await asyncio.sleep(0.1) + + assert voice_assistant_udp_server_v1.queue.qsize() == 1 + + voice_assistant_udp_server_v1.stop() + voice_assistant_udp_server_v1.close() + + assert voice_assistant_udp_server_v1.transport.is_closing() + + +async def test_udp_server_queue( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server queues incoming data.""" + + voice_assistant_udp_server_v1.started = True + + assert voice_assistant_udp_server_v1.queue.qsize() == 0 + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert voice_assistant_udp_server_v1.queue.qsize() == 1 + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert voice_assistant_udp_server_v1.queue.qsize() == 2 + + async for data in voice_assistant_udp_server_v1._iterate_packets(): + assert data == bytes(1024) + break + assert voice_assistant_udp_server_v1.queue.qsize() == 1 # One message removed + + voice_assistant_udp_server_v1.stop() + assert ( + voice_assistant_udp_server_v1.queue.qsize() == 2 + ) # An empty message added by stop + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert ( + voice_assistant_udp_server_v1.queue.qsize() == 2 + ) # No new messages added after stop + + voice_assistant_udp_server_v1.close() + + with pytest.raises(RuntimeError): + async for data in voice_assistant_udp_server_v1._iterate_packets(): + assert data == bytes(1024) + + +async def test_error_calls_handle_finished( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test that the handle_finished callback is called when an error occurs.""" + voice_assistant_udp_server_v1.handle_finished = Mock() + + voice_assistant_udp_server_v1.error_received(Exception()) + + voice_assistant_udp_server_v1.handle_finished.assert_called() + + +async def test_udp_server_multiple( + hass: HomeAssistant, + socket_enabled, + unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test that the UDP server raises an error if started twice.""" + with patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ): + await voice_assistant_udp_server_v1.start_server() + + with patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), pytest.raises(RuntimeError): + pass + await voice_assistant_udp_server_v1.start_server() + + +async def test_udp_server_after_stopped( + hass: HomeAssistant, + socket_enabled, + unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test that the UDP server raises an error if started after stopped.""" + voice_assistant_udp_server_v1.close() + with patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), pytest.raises(RuntimeError): + await voice_assistant_udp_server_v1.start_server() + + +async def test_unknown_event_type( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server does not call handle_event for unknown events.""" + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type="unknown-event", + data={}, + ) + ) + + assert not voice_assistant_udp_server_v1.handle_event.called + + +async def test_error_event_type( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls event handler with error.""" + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type=PipelineEventType.ERROR, + data={"code": "code", "message": "message"}, + ) + ) + + assert voice_assistant_udp_server_v1.handle_event.called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + {"code": "code", "message": "message"}, + ) + + +async def test_send_tts_not_called( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1 device does not call _send_tts.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + mock_send_tts.assert_not_called() + + +async def test_send_tts_called( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v2 device calls _send_tts.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + mock_send_tts.assert_called_with(_TEST_MEDIA_ID) + + +async def test_send_tts( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls sendto to transmit audio data to device.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + await voice_assistant_udp_server_v2._tts_done.wait() + + voice_assistant_udp_server_v2.transport.sendto.assert_called() diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py index 25b9c5e89a2..4c6497b975b 100644 --- a/tests/components/facebox/test_image_processing.py +++ b/tests/components/facebox/test_image_processing.py @@ -75,6 +75,12 @@ VALID_CONFIG = { } +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_healthybox(): """Mock fb.check_box_health.""" diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index 8c42e20b739..f60a06cb4c5 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -19,13 +19,16 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test fan registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/filesize/__init__.py b/tests/components/filesize/__init__.py index 3e09a745387..b8956d4fb80 100644 --- a/tests/components/filesize/__init__.py +++ b/tests/components/filesize/__init__.py @@ -1,13 +1,9 @@ """Tests for the filesize component.""" -import os from homeassistant.core import HomeAssistant -TEST_DIR = os.path.join(os.path.dirname(__file__)) TEST_FILE_NAME = "mock_file_test_filesize.txt" TEST_FILE_NAME2 = "mock_file_test_filesize2.txt" -TEST_FILE = os.path.join(TEST_DIR, TEST_FILE_NAME) -TEST_FILE2 = os.path.join(TEST_DIR, TEST_FILE_NAME2) async def async_create_file(hass: HomeAssistant, path: str) -> None: diff --git a/tests/components/filesize/conftest.py b/tests/components/filesize/conftest.py index 6584ebc95df..ac36ab687f4 100644 --- a/tests/components/filesize/conftest.py +++ b/tests/components/filesize/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -import os +from pathlib import Path from unittest.mock import patch import pytest @@ -10,19 +10,20 @@ import pytest from homeassistant.components.filesize.const import DOMAIN from homeassistant.const import CONF_FILE_PATH -from . import TEST_FILE, TEST_FILE2, TEST_FILE_NAME +from . import TEST_FILE_NAME from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(tmp_path: Path) -> MockConfigEntry: """Return the default mocked config entry.""" + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) return MockConfigEntry( title=TEST_FILE_NAME, domain=DOMAIN, - data={CONF_FILE_PATH: TEST_FILE}, - unique_id=TEST_FILE, + data={CONF_FILE_PATH: test_file}, + unique_id=test_file, ) @@ -33,13 +34,3 @@ def mock_setup_entry() -> Generator[None, None, None]: "homeassistant.components.filesize.async_setup_entry", return_value=True ): yield - - -@pytest.fixture(autouse=True) -def remove_file() -> None: - """Remove test file.""" - yield - if os.path.isfile(TEST_FILE): - os.remove(TEST_FILE) - if os.path.isfile(TEST_FILE2): - os.remove(TEST_FILE2) diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py index dd99aa2a723..27dec438168 100644 --- a/tests/components/filesize/test_config_flow.py +++ b/tests/components/filesize/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Filesize config flow.""" +from pathlib import Path from unittest.mock import patch import pytest @@ -9,54 +10,55 @@ from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import TEST_DIR, TEST_FILE, TEST_FILE_NAME, async_create_file +from . import TEST_FILE_NAME, async_create_file from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_user_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow(hass: HomeAssistant, tmp_path: Path) -> None: """Test the full user configuration flow.""" - await async_create_file(hass, TEST_FILE) - hass.config.allowlist_external_dirs = {TEST_DIR} + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) + await async_create_file(hass, test_file) + hass.config.allowlist_external_dirs = {tmp_path} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_FILE_PATH: TEST_FILE}, + user_input={CONF_FILE_PATH: test_file}, ) assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2.get("title") == TEST_FILE_NAME - assert result2.get("data") == {CONF_FILE_PATH: TEST_FILE} + assert result2.get("data") == {CONF_FILE_PATH: test_file} async def test_unique_path( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path ) -> None: """Test we abort if already setup.""" - await async_create_file(hass, TEST_FILE) - hass.config.allowlist_external_dirs = {TEST_DIR} + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) + await async_create_file(hass, test_file) + hass.config.allowlist_external_dirs = {tmp_path} mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_FILE_PATH: TEST_FILE} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_FILE_PATH: test_file} ) assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" -async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: +async def test_flow_fails_on_validation(hass: HomeAssistant, tmp_path: Path) -> None: """Test config flow errors.""" - + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) hass.config.allowlist_external_dirs = {} result = await hass.config_entries.flow.async_init( @@ -64,18 +66,18 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_FILE_PATH: TEST_FILE, + CONF_FILE_PATH: test_file, }, ) assert result2["errors"] == {"base": "not_valid"} - await async_create_file(hass, TEST_FILE) + await async_create_file(hass, test_file) with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", @@ -83,25 +85,25 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_FILE_PATH: TEST_FILE, + CONF_FILE_PATH: test_file, }, ) assert result2["errors"] == {"base": "not_allowed"} - hass.config.allowlist_external_dirs = {TEST_DIR} + hass.config.allowlist_external_dirs = {tmp_path} with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_FILE_PATH: TEST_FILE, + CONF_FILE_PATH: test_file, }, ) assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_FILE_NAME assert result2["data"] == { - CONF_FILE_PATH: TEST_FILE, + CONF_FILE_PATH: test_file, } diff --git a/tests/components/filesize/test_init.py b/tests/components/filesize/test_init.py index 7c0526b8194..c580bb7da77 100644 --- a/tests/components/filesize/test_init.py +++ b/tests/components/filesize/test_init.py @@ -1,5 +1,5 @@ """Tests for the Filesize integration.""" -import py +from pathlib import Path from homeassistant.components.filesize.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -12,12 +12,12 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path ) -> None: """Test the Filesize configuration entry loading/unloading.""" - testfile = f"{tmpdir}/file.txt" + testfile = str(tmp_path.joinpath("file.txt")) await async_create_file(hass, testfile) - hass.config.allowlist_external_dirs = {tmpdir} + hass.config.allowlist_external_dirs = {tmp_path} mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( mock_config_entry, unique_id=testfile, data={CONF_FILE_PATH: testfile} @@ -35,12 +35,12 @@ async def test_load_unload_config_entry( async def test_cannot_access_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path ) -> None: """Test that an file not exist is caught.""" mock_config_entry.add_to_hass(hass) - testfile = f"{tmpdir}/file_not_exist.txt" - hass.config.allowlist_external_dirs = {tmpdir} + testfile = str(tmp_path.joinpath("file_not_exist.txt")) + hass.config.allowlist_external_dirs = {tmp_path} hass.config_entries.async_update_entry( mock_config_entry, unique_id=testfile, data={CONF_FILE_PATH: testfile} ) @@ -52,10 +52,10 @@ async def test_cannot_access_file( async def test_not_valid_path_to_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path ) -> None: """Test that an invalid path is caught.""" - testfile = f"{tmpdir}/file.txt" + testfile = str(tmp_path.joinpath("file.txt")) await async_create_file(hass, testfile) mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index bef0eceb653..20354df13bd 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -1,24 +1,24 @@ """The tests for the filesize sensor.""" import os - -import py +from pathlib import Path from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -from . import TEST_FILE, TEST_FILE_NAME, async_create_file +from . import TEST_FILE_NAME, async_create_file from tests.common import MockConfigEntry async def test_invalid_path( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path ) -> None: """Test that an invalid path is caught.""" + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( - mock_config_entry, unique_id=TEST_FILE, data={CONF_FILE_PATH: TEST_FILE} + mock_config_entry, unique_id=test_file, data={CONF_FILE_PATH: test_file} ) state = hass.states.get("sensor." + TEST_FILE_NAME) @@ -26,12 +26,12 @@ async def test_invalid_path( async def test_valid_path( - hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmp_path: Path, mock_config_entry: MockConfigEntry ) -> None: """Test for a valid path.""" - testfile = f"{tmpdir}/file.txt" + testfile = str(tmp_path.joinpath("file.txt")) await async_create_file(hass, testfile) - hass.config.allowlist_external_dirs = {tmpdir} + hass.config.allowlist_external_dirs = {tmp_path} mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( mock_config_entry, unique_id=testfile, data={CONF_FILE_PATH: testfile} @@ -48,12 +48,12 @@ async def test_valid_path( async def test_state_unavailable( - hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmp_path: Path, mock_config_entry: MockConfigEntry ) -> None: """Verify we handle state unavailable.""" - testfile = f"{tmpdir}/file.txt" + testfile = str(tmp_path.joinpath("file.txt")) await async_create_file(hass, testfile) - hass.config.allowlist_external_dirs = {tmpdir} + hass.config.allowlist_external_dirs = {tmp_path} mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( mock_config_entry, unique_id=testfile, data={CONF_FILE_PATH: testfile} diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 5ac03aea13d..26df432a270 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -308,6 +308,12 @@ async def test_invalid_state(recorder_mock: Recorder, hass: HomeAssistant) -> No assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() + hass.states.async_set("sensor.test_monitored", "unknown") + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 007a6b7d2ae..60e9c9dc5d0 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -72,6 +72,7 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: estimate.api_rate_limit = 60 estimate.account_type.value = "public" estimate.energy_production_today = 100000 + estimate.energy_production_today_remaining = 50000 estimate.energy_production_tomorrow = 200000 estimate.power_production_now = 300000 estimate.power_highest_peak_time_today = datetime( @@ -99,7 +100,7 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 20, datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 200, } - estimate.wh_hours = { + estimate.wh_period = { datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 30, datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 300, } diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index 41cfb4d839e..2129821217e 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -24,7 +24,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/forecast_solar/test_diagnostics.py b/tests/components/forecast_solar/test_diagnostics.py index 6bb4c3c5780..4900c3bdb32 100644 --- a/tests/components/forecast_solar/test_diagnostics.py +++ b/tests/components/forecast_solar/test_diagnostics.py @@ -34,6 +34,7 @@ async def test_diagnostics( }, "data": { "energy_production_today": 100000, + "energy_production_today_remaining": 50000, "energy_production_tomorrow": 200000, "energy_current_hour": 800000, "power_production_now": 300000, @@ -45,7 +46,7 @@ async def test_diagnostics( "2021-06-27T13:00:00-07:00": 20, "2022-06-27T13:00:00-07:00": 200, }, - "wh_hours": { + "wh_period": { "2021-06-27T13:00:00-07:00": 30, "2022-06-27T13:00:00-07:00": 300, }, diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py index 9ab6038818b..3ca89d33faa 100644 --- a/tests/components/forecast_solar/test_energy.py +++ b/tests/components/forecast_solar/test_energy.py @@ -15,7 +15,7 @@ async def test_energy_solar_forecast( mock_forecast_solar: MagicMock, ) -> None: """Test the Forecast.Solar energy platform solar forecast.""" - mock_forecast_solar.estimate.return_value.wh_hours = { + mock_forecast_solar.estimate.return_value.wh_period = { datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, } diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 39d9103c486..4539619febc 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -48,6 +48,21 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes + state = hass.states.get("sensor.energy_production_today_remaining") + entry = entity_registry.async_get("sensor.energy_production_today_remaining") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_energy_production_today_remaining" + assert state.state == "50.0" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Solar production forecast Estimated energy production - remaining today" + ) + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert ATTR_ICON not in state.attributes + state = hass.states.get("sensor.energy_production_tomorrow") entry = entity_registry.async_get("sensor.energy_production_tomorrow") assert entry diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index fe442641e72..81357b6f3eb 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -60,7 +60,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_config_flow(hass: HomeAssistant, config_entry) -> None: diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 66718155af4..7bf1cbfe7a4 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -8,6 +8,7 @@ from homeassistant.helpers import device_registry as dr from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, + DATA_HOME_GET_NODES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_SYSTEM_GET_CONFIG, @@ -55,6 +56,8 @@ def mock_router(mock_device_registry_devices): # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) + # home devices + instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( return_value=DATA_CONNECTION_GET_STATUS ) diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 25402cbcdef..96fe96c19c5 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -410,3 +410,1653 @@ DATA_LAN_GET_HOSTS_LIST = [ "primary_name": "iPhoneofQuentin", }, ] + + +DATA_HOME_GET_NODES = [ + { + "adapter": 2, + "area": 38, + "category": "camera", + "group": {"label": "Salon"}, + "id": 16, + "label": "Caméra II", + "name": "node_16", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection", + "name": "detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": {"access": "rw", "display": "disk"}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 2, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [...], + }, + "value": 100, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [...], + "status_text_range": [...], + "unit": "dB", + }, + "value": -75, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png", + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal", + }, + ], + "signal_links": [], + "slot_links": [{...}], + "status": "active", + "type": { + "abstract": False, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": {"access": "rw", "display": "disk"}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 2, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [...], + }, + "value": 80, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [...], + "status_text_range": [...], + "unit": "dB", + }, + "value": -49, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png", + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal", + }, + ], + "generic": False, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": True, + }, + }, + { + "adapter": 1, + "area": 38, + "category": "camera", + "group": {"label": "Salon"}, + "id": 15, + "label": "Caméra I", + "name": "node_15", + "props": { + "Ip": "192.169.0.2", + "Login": "camfreebox", + "Mac": "34:2d:f2:e5:9d:ff", + "Pass": "xxxxx", + "Stream": "http://freeboxcam:mv...tream.m3u8", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": {"access": "rw", "display": "disk"}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 2, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [...], + }, + "value": 100, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [...], + "status_text_range": [...], + "unit": "dB", + }, + "value": -75, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png", + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal", + }, + ], + "signal_links": [], + "slot_links": [{...}], + "status": "active", + "type": { + "abstract": False, + "endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Détection ", + "name": "detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Activé avec l'alarme", + "name": "activation", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Haute qualité vidéo", + "name": "quality", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 3, + "label": "Sensibilité", + "name": "sensitivity", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 4, + "label": "Seuil", + "name": "threshold", + "ui": {"access": "rw", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Retourner verticalement", + "name": "flip", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Horodatage", + "name": "timestamp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Volume du micro", + "name": "volume", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 9, + "label": "Détection de bruit", + "name": "sound_detection", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 10, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "ui": {"access": "w", "display": "slider", "range": [...]}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 11, + "label": "Flux rtsp", + "name": "rtsp", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 12, + "label": "Emplacement des vidéos", + "name": "disk", + "ui": {"access": "rw", "display": "disk"}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 13, + "label": "Détection ", + "name": "detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/xxxx.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Activé avec l'alarme", + "name": "activation", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Ce réglage permet d'activer l'enregistrement de la caméra uniquement quand l'alarme est activée.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/alert_toggle.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Haute qualité vidéo", + "name": "quality", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Les vidéos seront enregistrées en 720p en haute qualité et 480p en qualité réduite.\r\n\r\nNous vous recommandons de laisser cette option désactivée si vous avez des difficultés de lecture des fichiers à distance.", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Flux.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 16, + "label": "Sensibilité", + "name": "sensitivity", + "refresh": 2000, + "ui": { + "access": "r", + "description": "La sensibilité définit la faculté d'un pixel à être sensible aux changements.\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute, plus les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 17, + "label": "Seuil", + "name": "threshold", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Le seuil définit le nombre de pixels devant changer pour déclencher la détection .\r\n\r\nQuatre réglages sont disponibles (1-4). Plus cette valeur est haute moins les déclenchements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 2, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 18, + "label": "Retourner verticalement", + "name": "flip", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Retour.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Horodatage", + "name": "timestamp", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/Horloge.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 20, + "label": "Volume du micro", + "name": "volume", + "refresh": 2000, + "ui": { + "access": "r", + "display": "slider", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + "range": [...], + }, + "value": 80, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 22, + "label": "Détection de bruit", + "name": "sound_detection", + "refresh": 2000, + "ui": { + "access": "r", + "display": "toggle", + "icon_url": "/resources/images/home/pictos/commande_vocale.png", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 23, + "label": "Sensibilité du micro", + "name": "sound_trigger", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Quatre réglages sont disponibles pour la sensibilité du micro (1-4).\r\n\r\nPlus cette valeur est haute, plus les enregistrements seront fréquents.", + "display": "slider", + "range": [...], + }, + "value": 3, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 24, + "label": "Flux rtsp", + "name": "rtsp", + "refresh": 2000, + "ui": { + "access": "r", + "description": "Active le flux RTSP à l'adresse rtsp://ip_camera/live", + "display": "toggle", + }, + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 25, + "label": "Niveau de réception", + "name": "rssi", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/reception_%.png", + "range": [...], + "status_text_range": [...], + "unit": "dB", + }, + "value": -49, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 26, + "label": "Emplacement des vidéos", + "name": "disk", + "refresh": 2000, + "ui": { + "access": "r", + "display": "disk", + "icon_url": "/resources/images/home/pictos/directory.png", + }, + "value": "Freebox", + "value_type": "string", + "visibility": "normal", + }, + ], + "generic": False, + "icon": "/resources/images/ho...camera.png", + "inherit": "node::cam", + "label": "Caméra Freebox", + "name": "node::cam::freebox", + "params": {}, + "physical": True, + }, + }, + { + "adapter": 5, + "category": "kfb", + "group": {"label": ""}, + "id": 9, + "label": "Télécommande I", + "name": "node_9", + "props": { + "Address": 5, + "Challenge": "65ae6b4def41f3e3a5a77ec63e988", + "FwVersion": 29798318, + "Gateway": 1, + "ItemId": "e76c2b75a4a6e2", + }, + "show_endpoints": [{...}, {...}, {...}, {...}], + "signal_links": [{...}], + "slot_links": [], + "status": "active", + "type": { + "abstract": False, + "endpoints": [...], + "generic": False, + "icon": "/resources/images/home/pictos/telecommande.png", + "inherit": "node::domus", + "label": "Télécommande pour alarme", + "name": "node::domus::sercomm::keyfob", + "params": {}, + "physical": True, + }, + }, + { + "adapter": 5, + "area": 40, + "category": "dws", + "group": {"label": "Entrée"}, + "id": 11, + "label": "dws i", + "name": "node_11", + "props": { + "Address": 6, + "Challenge": "964a2dddf2c40c3e2384f66d2", + "FwVersion": 29798220, + "Gateway": 1, + "ItemId": "9eff759dd553de7", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/batt_%.png", + "range": [...], + "status_text_range": [...], + "unit": "%", + }, + "value": 100, + "value_type": "int", + "visibility": "normal", + }, + ], + "signal_links": [{...}], + "slot_links": [], + "status": "active", + "type": { + "abstract": False, + "endpoints": [...], + "generic": False, + "icon": "/resources/images/home/pictos/detecteur_ouverture.png", + "inherit": "node::domus", + "label": "Détecteur d'ouverture de porte", + "name": "node::domus::sercomm::doorswitch", + "params": {}, + "physical": True, + }, + }, + { + "adapter": 5, + "area": 38, + "category": "pir", + "group": {"label": "Salon"}, + "id": 26, + "label": "Salon Détecteur s", + "name": "node_26", + "props": { + "Address": 9, + "Challenge": "ed2cc17f179862f5242256b3f597c367", + "FwVersion": 29871925, + "Gateway": 1, + "ItemId": "240d000f9fefe576", + }, + "show_endpoints": [ + {...}, + {...}, + {...}, + {...}, + {...}, + {...}, + {...}, + {...}, + {...}, + ], + "signal_links": [{...}], + "slot_links": [], + "status": "active", + "type": { + "abstract": False, + "endpoints": [...], + "generic": False, + "icon": "/resources/images/home/pictos/detecteur_xxxx.png", + "inherit": "node::domus", + "label": "Détecteur infrarouge", + "name": "node::domus::sercomm::pir", + "params": {}, + "physical": True, + }, + }, + { + "adapter": 10, + "area": 38, + "category": "shutter", + "group": {"label": "Salon"}, + "id": 150, + "label": "Shutter 1", + "name": "node_150", + "type": { + "inherit": "node::trs", + }, + }, + { + "adapter": 11, + "area": 38, + "category": "shutter", + "group": {"label": "Salon"}, + "id": 151, + "label": "Shutter 2", + "name": "node_151", + "type": { + "inherit": "node::ios", + }, + }, +] diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index dc55ba037ba..cab8605d865 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -25,7 +25,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_invalid_auth(hass: HomeAssistant) -> None: diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 612058af0a1..524b985b125 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -415,3 +415,82 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_auth" + + +async def test_reauth_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_reauth_flow_friendly_name_error( + hass: HomeAssistant, + exception: Exception, + reason: str, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with failures.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] == {"base": reason} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 5527cd367af..566f3b6d292 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -28,7 +28,7 @@ async def test_user_flow( DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -117,7 +117,7 @@ async def test_duplicate_updates_existing_entry( DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 88173499752..e7668bdc3ff 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -1,4 +1,5 @@ """Test The generic (IP Camera) config flow.""" +import contextlib import errno from http import HTTPStatus import os.path @@ -330,7 +331,10 @@ async def test_still_template( expected_errors, ) -> None: """Test we can handle various templates.""" - respx.get(url).respond(stream=fakeimgbytes_png) + with contextlib.suppress(httpx.InvalidURL): + # There is no need to mock the request if its an + # invalid url because we will never make the request + respx.get(url).respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index d8b5df566de..ab73fc1e75f 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -18,7 +18,7 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test a successful setup entry.""" await init_integration(hass) - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == "4" diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 6c87222c50a..82027d2bdb9 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -131,7 +131,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "123-o3-index" - state = hass.states.get("sensor.home_particulate_matter_10_mm") + state = hass.states.get("sensor.home_pm10") assert state assert state.state == "16.8344" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -142,11 +142,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_particulate_matter_10_mm") + entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" - state = hass.states.get("sensor.home_particulate_matter_10_mm_index") + state = hass.states.get("sensor.home_pm10_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -160,11 +160,11 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_particulate_matter_10_mm_index") + entry = registry.async_get("sensor.home_pm10_index") assert entry assert entry.unique_id == "123-pm10-index" - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -175,11 +175,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_particulate_matter_2_5_mm") + entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -193,7 +193,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_particulate_matter_2_5_mm_index") + entry = registry.async_get("sensor.home_pm2_5_index") assert entry assert entry.unique_id == "123-pm25-index" @@ -257,11 +257,11 @@ async def test_availability(hass: HomeAssistant) -> None: await init_integration(hass) - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == "good" @@ -277,11 +277,11 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == STATE_UNAVAILABLE @@ -300,7 +300,7 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" @@ -310,7 +310,7 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE # Indexes are empty so the state should be unavailable - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == STATE_UNAVAILABLE @@ -324,11 +324,11 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_particulate_matter_2_5_mm") + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == "good" @@ -349,11 +349,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_particulate_matter_10_mm_index") + state = hass.states.get("sensor.home_pm10_index") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_particulate_matter_2_5_mm_index") + state = hass.states.get("sensor.home_pm2_5_index") assert state assert state.state == STATE_UNAVAILABLE @@ -373,12 +373,12 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: PLATFORM, DOMAIN, "123-pm2.5", - suggested_object_id="home_particulate_matter_2_5_mm", + suggested_object_id="home_pm2_5", disabled_by=None, ) await init_integration(hass) - entry = registry.async_get("sensor.home_particulate_matter_2_5_mm") + entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 4818e9258de..8c9394ae84f 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,6 +1,8 @@ """Tests for Glances.""" -MOCK_USER_INPUT = { +from typing import Any + +MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", @@ -30,6 +32,85 @@ MOCK_DATA = { "key": "disk_name", }, ], + "docker": { + "containers": [ + { + "key": "name", + "name": "container1", + "Status": "running", + "cpu": {"total": 50.94973493230174}, + "cpu_percent": 50.94973493230174, + "memory": { + "usage": 1120321536, + "limit": 3976318976, + "rss": 480641024, + "cache": 580915200, + "max_usage": 1309597696, + }, + "memory_usage": 539406336, + }, + { + "key": "name", + "name": "container2", + "Status": "running", + "cpu": {"total": 26.23567931034483}, + "cpu_percent": 26.23567931034483, + "memory": { + "usage": 85139456, + "limit": 3976318976, + "rss": 33677312, + "cache": 35012608, + "max_usage": 87650304, + }, + "memory_usage": 50126848, + }, + ] + }, + "fs": [ + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/ssl", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/media", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + ], + "mem": { + "total": 3976318976, + "available": 2878337024, + "percent": 27.6, + "used": 1097981952, + "free": 2878337024, + "active": 567971840, + "inactive": 1679704064, + "buffers": 149807104, + "cached": 1334816768, + "shared": 1499136, + }, + "sensors": [ + { + "label": "cpu_thermal 1", + "value": 59, + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_core", + "key": "label", + } + ], "system": { "os_name": "Linux", "hostname": "fedora-35", @@ -40,3 +121,17 @@ MOCK_DATA = { }, "uptime": "3 days, 10:25:20", } + +HA_SENSOR_DATA: dict[str, Any] = { + "fs": { + "/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}}, + "mem": { + "memory_use_percent": 27.6, + "memory_use": 1047.1, + "memory_free": 2745.0, + }, + "docker": {"docker_active": 2, "docker_cpu_use": 77.2, "docker_memory_use": 1149.6}, +} diff --git a/tests/components/glances/conftest.py b/tests/components/glances/conftest.py index d92d3cc33d4..9f4590ab5e0 100644 --- a/tests/components/glances/conftest.py +++ b/tests/components/glances/conftest.py @@ -3,13 +3,14 @@ from unittest.mock import AsyncMock, patch import pytest -from . import MOCK_DATA +from . import HA_SENSOR_DATA @pytest.fixture(autouse=True) def mock_api(): """Mock glances api.""" with patch("homeassistant.components.glances.Glances") as mock_api: - mock_api.return_value.get_data = AsyncMock(return_value=None) - mock_api.return_value.data.return_value = MOCK_DATA + mock_api.return_value.get_ha_sensor_data = AsyncMock( + return_value=HA_SENSOR_DATA + ) yield mock_api diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index ab642055059..187e319fe08 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -42,7 +42,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test to return error if we cannot connect.""" - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 944d9d55ae2..546f57ac3d9 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -29,7 +29,7 @@ async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py new file mode 100644 index 00000000000..e5aadc92156 --- /dev/null +++ b/tests/components/glances/test_sensor.py @@ -0,0 +1,69 @@ +"""Tests for glances sensors.""" +import pytest + +from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HA_SENSOR_DATA, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_sensor_states(hass: HomeAssistant) -> None: + """Test sensor states are correctly collected from library.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): + assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + + if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): + assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id", "new_unique_id"), + [ + ( + "glances_ssl_used_percent", + "0.0.0.0-Glances /ssl used percent", + "/ssl-disk_use_percent", + ), + ( + "glances_cpu_thermal_1_temperature", + "0.0.0.0-Glances cpu_thermal 1 Temperature", + "cpu_thermal 1-temperature_core", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str +): + """Test unique id migration.""" + old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} + entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index 3f7b536f163..5f033319c44 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -20,6 +20,7 @@ async def test_diagnostics( await setup.async_setup_component( hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} ) + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index d0a7df5a863..f9ea356216f 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -128,7 +128,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: ) light.hass = hass light.entity_id = "light.demo_light" - await light.async_update_ha_state() + light.async_write_ha_state() # This should not show up in the sync request hass.states.async_set("sensor.no_match", "something") @@ -268,7 +268,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> ) light.hass = hass light.entity_id = entity.entity_id - await light.async_update_ha_state() + light.async_write_ha_state() config = MockConfig(should_expose=lambda _: True, entity_config={}) @@ -360,19 +360,19 @@ async def test_query_message(hass: HomeAssistant) -> None: ) light.hass = hass light.entity_id = "light.demo_light" - await light.async_update_ha_state() + light.async_write_ha_state() light2 = DemoLight( None, "Another Light", state=True, hs_color=(180, 75), ct=400, brightness=78 ) light2.hass = hass light2.entity_id = "light.another_light" - await light2.async_update_ha_state() + light2.async_write_ha_state() light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200) light3.hass = hass light3.entity_id = "light.color_temp_light" - await light3.async_update_ha_state() + light3.async_write_ha_state() events = async_capture_events(hass, EVENT_QUERY_RECEIVED) @@ -451,6 +451,7 @@ async def test_execute( hass: HomeAssistant, report_state, on, brightness, value ) -> None: """Test an execute command.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await hass.async_block_till_done() @@ -635,6 +636,7 @@ async def test_execute_times_out( orig_execute_limit = sh.EXECUTE_LIMIT sh.EXECUTE_LIMIT = 0.02 # Decrease timeout to 20ms await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) + await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() await hass.services.async_call( @@ -931,7 +933,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: light.hass = hass light.entity_id = "light.demo_light" light._available = False - await light.async_update_ha_state() + light.async_write_ha_state() events = async_capture_events(hass, EVENT_SYNC_RECEIVED) @@ -1025,7 +1027,7 @@ async def test_device_class_switch( ) sensor.hass = hass sensor.entity_id = "switch.demo_sensor" - await sensor.async_update_ha_state() + sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1072,7 +1074,7 @@ async def test_device_class_binary_sensor( ) sensor.hass = hass sensor.entity_id = "binary_sensor.demo_sensor" - await sensor.async_update_ha_state() + sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1123,7 +1125,7 @@ async def test_device_class_cover( sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass sensor.entity_id = "cover.demo_sensor" - await sensor.async_update_ha_state() + sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1170,7 +1172,7 @@ async def test_device_media_player( sensor = AbstractDemoPlayer("Demo", device_class=device_class) sensor.hass = hass sensor.entity_id = "media_player.demo" - await sensor.async_update_ha_state() + sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1454,7 +1456,7 @@ async def test_sync_message_recovery( ) light.hass = hass light.entity_id = "light.demo_light" - await light.async_update_ha_state() + light.async_write_ha_state() hass.states.async_set( "light.bad_light", diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 293b42fa57b..884107b7eb9 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import call, patch import aiohttp import pytest +from homeassistant.components import conversation from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -208,6 +209,7 @@ async def test_send_text_command_expired_token_refresh_failure( requires_reauth: ConfigEntryState, ) -> None: """Test failure refreshing token in send_text_command.""" + await async_setup_component(hass, "homeassistant", {}) await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) @@ -334,6 +336,9 @@ async def test_conversation_agent( ) await hass.async_block_till_done() + agent = await conversation._get_agent_manager(hass).async_get_agent(entry.entry_id) + assert agent.supported_languages == ["en-US"] + text1 = "tell me a joke" text2 = "tell me another one" with patch( diff --git a/tests/components/google_mail/fixtures/get_vacation_no_dates.json b/tests/components/google_mail/fixtures/get_vacation_no_dates.json new file mode 100644 index 00000000000..05abae4c705 --- /dev/null +++ b/tests/components/google_mail/fixtures/get_vacation_no_dates.json @@ -0,0 +1,6 @@ +{ + "enableAutoReply": true, + "responseSubject": "Vacation", + "responseBodyPlainText": "I am on vacation.", + "restrictToContacts": false +} diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 369557ad3e9..248622d3157 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from google.auth.exceptions import RefreshError from httplib2 import Response +import pytest from homeassistant import config_entries from homeassistant.components.google_mail.const import DOMAIN @@ -17,7 +18,17 @@ from .conftest import SENSOR, TOKEN, ComponentSetup from tests.common import async_fire_time_changed, load_fixture -async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: +@pytest.mark.parametrize( + ("fixture", "result"), + [ + ("get_vacation", "2022-11-18T05:00:00+00:00"), + ("get_vacation_no_dates", STATE_UNKNOWN), + ("get_vacation_off", STATE_UNKNOWN), + ], +) +async def test_sensors( + hass: HomeAssistant, setup_integration: ComponentSetup, fixture: str, result: str +) -> None: """Test we get sensor data.""" await setup_integration() @@ -29,7 +40,7 @@ async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) - "httplib2.Http.request", return_value=( Response({}), - bytes(load_fixture("google_mail/get_vacation_off.json"), encoding="UTF-8"), + bytes(load_fixture(f"google_mail/{fixture}.json"), encoding="UTF-8"), ), ): next_update = dt_util.utcnow() + timedelta(minutes=15) @@ -37,7 +48,7 @@ async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) - await hass.async_block_till_done() state = hass.states.get(SENSOR) - assert state.state == STATE_UNKNOWN + assert state.state == result async def test_sensor_reauth_trigger( diff --git a/tests/components/group/conftest.py b/tests/components/group/conftest.py index e26e98598e6..3aefbfacdf8 100644 --- a/tests/components/group/conftest.py +++ b/tests/components/group/conftest.py @@ -1,2 +1,13 @@ """group conftest.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 2c100a2e3cb..3ca965ec998 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER from homeassistant.components.recorder import Recorder @@ -16,6 +18,11 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in group.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" now = dt_util.utcnow() @@ -38,7 +45,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index afe641405e3..678ba641e80 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -12,6 +12,8 @@ from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN +from tests.test_util.aiohttp import AiohttpClientMocker + @pytest.fixture(autouse=True) def disable_security_filter(): @@ -89,3 +91,77 @@ async def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1") + + +@pytest.fixture +def all_setup_requests( + aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest +): + """Mock all setup requests.""" + 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"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/store", + json={ + "result": "ok", + "data": {"addons": [], "repositories": []}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [], + }, + }, + ) + aioclient_mock.get( + "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"}) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 51659927dfa..5c4717fd561 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -89,6 +89,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", + uuid="test", ) ) @@ -153,6 +154,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", + uuid="test", ) ) @@ -207,5 +209,6 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", + uuid="test", ) ) diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index c7075dba932..e980bf214a0 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -7,7 +7,9 @@ import aiohttp from aiohttp import hdrs, web import pytest +from homeassistant.components.hassio import handler from homeassistant.components.hassio.handler import HassIO, HassioAPIError +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from tests.test_util.aiohttp import AiohttpClientMocker @@ -360,3 +362,54 @@ async def test_api_headers( assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json" else: assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" + + +async def test_api_get_yellow_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/yellow", + json={ + "result": "ok", + "data": {"disk_led": True, "heartbeat_led": True, "power_led": True}, + }, + ) + + assert await handler.async_get_yellow_settings(hass) == { + "disk_led": True, + "heartbeat_led": True, + "power_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_yellow_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/yellow", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_yellow_settings( + hass, {"disk_led": True, "heartbeat_led": True, "power_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + +async def test_api_reboot_host( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/host/reboot", + json={"result": "ok", "data": {}}, + ) + + assert await handler.async_reboot_host(hass) == {} + assert aioclient_mock.call_count == 1 diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 5b280d0c827..c8ce5fcb490 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -1,6 +1,7 @@ """Test issues from supervisor issues.""" from __future__ import annotations +from asyncio import TimeoutError import os from typing import Any from unittest.mock import ANY, patch @@ -24,75 +25,8 @@ async def setup_repairs(hass): @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest): +async def mock_all(all_setup_requests): """Mock all setup requests.""" - 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"}) - aioclient_mock.get( - "http://127.0.0.1/info", - json={ - "result": "ok", - "data": { - "supervisor": "222", - "homeassistant": "0.110.0", - "hassos": "1.2.3", - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/store", - json={ - "result": "ok", - "data": {"addons": [], "repositories": []}, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/host/info", - json={ - "result": "ok", - "data": { - "result": "ok", - "data": { - "chassis": "vm", - "operating_system": "Debian GNU/Linux 10 (buster)", - "kernel": "4.19.0-6-amd64", - }, - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, - ) - aioclient_mock.get( - "http://127.0.0.1/os/info", - json={ - "result": "ok", - "data": { - "version_latest": "1.0.0", - "version": "1.0.0", - "update_available": False, - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/supervisor/info", - json={ - "result": "ok", - "data": { - "result": "ok", - "version": "1.0.0", - "version_latest": "1.0.0", - "auto_update": True, - "addons": [], - }, - }, - ) - aioclient_mock.get( - "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"}) @pytest.fixture(autouse=True) @@ -106,8 +40,9 @@ def mock_resolution_info( aioclient_mock: AiohttpClientMocker, unsupported: list[str] | None = None, unhealthy: list[str] | None = None, + issues: list[dict[str, str]] | None = None, ): - """Mock resolution/info endpoint with unsupported/unhealthy reasons.""" + """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" aioclient_mock.get( "http://127.0.0.1/resolution/info", json={ @@ -116,7 +51,12 @@ def mock_resolution_info( "unsupported": unsupported or [], "unhealthy": unhealthy or [], "suggestions": [], - "issues": [], + "issues": [ + {k: v for k, v in issue.items() if k != "suggestions"} + for issue in issues + ] + if issues + else [], "checks": [ {"enabled": True, "slug": "supervisor_trust"}, {"enabled": True, "slug": "free_space"}, @@ -125,6 +65,21 @@ def mock_resolution_info( }, ) + if issues: + suggestions_by_issue = { + issue["uuid"]: issue.get("suggestions", []) for issue in issues + } + for issue_uuid, suggestions in suggestions_by_issue.items(): + aioclient_mock.get( + f"http://127.0.0.1/resolution/issue/{issue_uuid}/suggestions", + json={"result": "ok", "data": {"suggestions": suggestions}}, + ) + for suggestion in suggestions: + aioclient_mock.post( + f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", + json={"result": "ok"}, + ) + def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: str): """Assert repair for unhealthy/unsupported in list.""" @@ -145,6 +100,31 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues +def assert_issue_repair_in_list( + issues: list[dict[str, Any]], + uuid: str, + context: str, + type_: str, + fixable: bool, + reference: str | None, +): + """Assert repair for unhealthy/unsupported in list.""" + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": fixable, + "issue_id": uuid, + "issue_domain": None, + "learn_more_url": None, + "severity": "warning", + "translation_key": f"issue_{context}_{type_}", + "translation_placeholders": {"reference": reference} if reference else None, + } in issues + + async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -306,8 +286,20 @@ async def test_reset_issues_supervisor_restart( aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy issues reset on supervisor restart.""" - mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + """All issues reset on supervisor restart.""" + mock_resolution_info( + aioclient_mock, + unsupported=["os"], + unhealthy=["docker"], + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + } + ], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -317,9 +309,17 @@ async def test_reset_issues_supervisor_restart( await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] - assert len(msg["result"]["issues"]) == 2 + assert len(msg["result"]["issues"]) == 3 assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) aioclient_mock.clear_requests() mock_resolution_info(aioclient_mock) @@ -462,3 +462,256 @@ async def test_new_unsupported_unhealthy_reason( "translation_key": "unsupported", "translation_placeholders": {"reason": "fake_unsupported"}, } in msg["result"]["issues"] + + +async def test_supervisor_issues( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test repairs added for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + { + "uuid": "1235", + "type": "multiple_data_disks", + "context": "system", + "reference": "/dev/sda1", + "suggestions": [ + { + "uuid": "1236", + "type": "rename_data_disk", + "context": "system", + "reference": "/dev/sda1", + } + ], + }, + { + "uuid": "1237", + "type": "should_not_be_repair", + "context": "fake", + "reference": None, + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1235", + context="system", + type_="multiple_data_disks", + fixable=True, + reference="/dev/sda1", + ) + + +async def test_supervisor_issues_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issues added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + } + ], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=True, + reference=None, + ) + + await client.send_json( + { + "id": 5, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 6, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_supervisor_issues_suggestions_fail( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test failing to get suggestions for issue skips it.""" + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + } + ], + "checks": [ + {"enabled": True, "slug": "supervisor_trust"}, + {"enabled": True, "slug": "free_space"}, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/resolution/issue/1234/suggestions", + exc=TimeoutError(), + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +async def test_supervisor_remove_missing_issue_without_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test HA skips message to remove issue that it didn't know about (sync issue).""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py new file mode 100644 index 00000000000..76b6b48b460 --- /dev/null +++ b/tests/components/hassio/test_repairs.py @@ -0,0 +1,402 @@ +"""Test supervisor repairs.""" + +from http import HTTPStatus +import os +from unittest.mock import patch + +import pytest + +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 .test_init import MOCK_ENVIRON +from .test_issues import mock_resolution_info + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def setup_repairs(hass): + """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.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +async def test_supervisor_issue_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test fix flow for supervisor issue.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "multiple_data_disks", + "context": "system", + "reference": "/dev/sda1", + "suggestions": [ + { + "uuid": "1235", + "type": "rename_data_disk", + "context": "system", + "reference": "/dev/sda1", + } + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + 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_rename_data_disk", + "data_schema": [], + "errors": None, + "description_placeholders": {"reference": "/dev/sda1"}, + "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" + ) + + +async def test_supervisor_issue_repair_flow_with_multiple_suggestions( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test fix flow for supervisor issue with multiple suggestions.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": "test", + }, + { + "uuid": "1236", + "type": "test_type", + "context": "system", + "reference": "test", + }, + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + 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": [ + ["system_execute_reboot", "system_execute_reboot"], + ["system_test_type", "system_test_type"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["system_execute_reboot", "system_test_type"], + "description_placeholders": {"reference": "test"}, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", json={"next_step_id": "system_test_type"} + ) + + 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/1236" + ) + + +async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confirmation( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> 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=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + }, + { + "uuid": "1236", + "type": "test_type", + "context": "system", + "reference": None, + }, + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + 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": [ + ["system_execute_reboot", "system_execute_reboot"], + ["system_test_type", "system_test_type"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["system_execute_reboot", "system_test_type"], + "description_placeholders": None, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "system_execute_reboot"}, + ) + + 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_reboot", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "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" + ) + + +async def test_supervisor_issue_repair_flow_skip_confirmation( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> 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=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + } + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + 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_reboot", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "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 8b46bd97602..30c84c56f00 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -11,14 +11,7 @@ from homeassistant.components import history from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.models import process_timestamp -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_INCLUDE, - EVENT_HOMEASSISTANT_FINAL_WRITE, -) -import homeassistant.core as ha +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -59,7 +52,7 @@ def test_get_significant_states(hass_history) -> None: """ hass = hass_history zero, four, states = record_states(hass) - hist = get_significant_states(hass, zero, four, filters=history.Filters()) + hist = get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -76,7 +69,7 @@ def test_get_significant_states_minimal_response(hass_history) -> None: hass = hass_history zero, four, states = record_states(hass) hist = get_significant_states( - hass, zero, four, filters=history.Filters(), minimal_response=True + hass, zero, four, minimal_response=True, entity_ids=list(states) ) entites_with_reducable_states = [ "media_player.test", @@ -137,21 +130,20 @@ def test_get_significant_states_with_initial(hass_history) -> None: """ hass = hass_history zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) one_and_half = zero + timedelta(seconds=1.5) for entity_id in states: if entity_id == "media_player.test": states[entity_id] = states[entity_id][1:] for state in states[entity_id]: - if state.last_changed == one: + # If the state is recorded before the start time + # start it will have its last_updated and last_changed + # set to the start time. + if state.last_updated < one_and_half: + state.last_updated = one_and_half state.last_changed = one_and_half hist = get_significant_states( - hass, - one_and_half, - four, - filters=history.Filters(), - include_start_time_state=True, + hass, one_and_half, four, include_start_time_state=True, entity_ids=list(states) ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -182,8 +174,8 @@ def test_get_significant_states_without_initial(hass_history) -> None: hass, one_and_half, four, - filters=history.Filters(), include_start_time_state=False, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -198,9 +190,7 @@ def test_get_significant_states_entity_id(hass_history) -> None: del states["thermostat.test2"] del states["script.can_cancel_this_one"] - hist = get_significant_states( - hass, zero, four, ["media_player.test"], filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, ["media_player.test"]) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -218,247 +208,10 @@ def test_get_significant_states_multiple_entity_ids(hass_history) -> None: zero, four, ["media_player.test", "thermostat.test"], - filters=history.Filters(), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_exclude_domain(hass_history) -> None: - """Test if significant states are returned when excluding domains. - - We should get back every thermostat change that includes an attribute - change, but no media player changes. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["media_player"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude_entity(hass_history) -> None: - """Test if significant states are returned when excluding entities. - - We should get back every thermostat and script changes, but no media - player changes. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: ["media_player.test"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude(hass_history) -> None: - """Test significant states when excluding entities and domains. - - We should not get back every thermostat and media player test changes. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["thermostat.test"] - del states["thermostat.test2"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - } - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude_include_entity(hass_history) -> None: - """Test significant states when excluding domains and include entities. - - We should not get back every thermostat change unless its specifically included - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["thermostat.test2"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test", "thermostat.test"]}, - CONF_EXCLUDE: {CONF_DOMAINS: ["thermostat"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_domain(hass_history) -> None: - """Test if significant states are returned when including domains. - - We should get back every thermostat and script changes, but no media - player changes. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_INCLUDE: {CONF_DOMAINS: ["thermostat", "script"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_entity(hass_history) -> None: - """Test if significant states are returned when including entities. - - We should only get back changes of the media_player.test entity. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include(hass_history) -> None: - """Test significant states when including domains and entities. - - We should only get back changes of the media_player.test entity and the - thermostat domain. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - } - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude_domain(hass_history) -> None: - """Test if significant states when excluding and including domains. - - We should get back all the media_player domain changes - only since the include wins over the exclude but will - exclude everything else. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_DOMAINS: ["media_player"]}, - CONF_EXCLUDE: {CONF_DOMAINS: ["media_player"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude_entity(hass_history) -> None: - """Test if significant states when excluding and including domains. - - We should not get back any changes since we include only - media_player.test but also exclude it. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test"]}, - CONF_EXCLUDE: {CONF_ENTITIES: ["media_player.test"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude(hass_history) -> None: - """Test if significant states when in/excluding domains and entities. - - We should get back changes of the media_player.test2, media_player.test3, - and thermostat.test. - """ - hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["media_player"], - CONF_ENTITIES: ["thermostat.test"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - }, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - def test_get_significant_states_are_ordered(hass_history) -> None: """Test order of results from get_significant_states. @@ -468,14 +221,10 @@ def test_get_significant_states_are_ordered(hass_history) -> None: hass = hass_history zero, four, _states = record_states(hass) entity_ids = ["media_player.test", "media_player.test2"] - hist = get_significant_states( - hass, zero, four, entity_ids, filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids entity_ids = ["media_player.test2", "media_player.test"] - hist = get_significant_states( - hass, zero, four, entity_ids, filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -522,7 +271,12 @@ def test_get_significant_states_only(hass_history) -> None: # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) - hist = get_significant_states(hass, start, significant_changes_only=True) + hist = get_significant_states( + hass, + start, + significant_changes_only=True, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 2 assert not any( @@ -535,7 +289,12 @@ def test_get_significant_states_only(hass_history) -> None: state.last_updated == states[2].last_updated for state in hist[entity_id] ) - hist = get_significant_states(hass, start, significant_changes_only=False) + hist = get_significant_states( + hass, + start, + significant_changes_only=False, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 3 assert_multiple_states_equal_without_context_and_last_changed( @@ -545,16 +304,7 @@ def test_get_significant_states_only(hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - domain_config = config[history.DOMAIN] - exclude = domain_config.get(CONF_EXCLUDE, {}) - include = domain_config.get(CONF_INCLUDE, {}) - filters = history.Filters( - excluded_entities=exclude.get(CONF_ENTITIES, []), - excluded_domains=exclude.get(CONF_DOMAINS, []), - included_entities=include.get(CONF_ENTITIES, []), - included_domains=include.get(CONF_DOMAINS, []), - ) - hist = get_significant_states(hass, zero, four, filters=filters) + hist = get_significant_states(hass, zero, four) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -649,21 +399,30 @@ async def test_fetch_period_api( """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=sensor.power" + ) assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_use_include_order( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the fetch period view for history with include order.""" await async_setup_component( hass, "history", {history.DOMAIN: {history.CONF_ORDER: True}} ) client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=sensor.power" + ) assert response.status == HTTPStatus.OK + assert "The 'use_include_order' option is deprecated" in caplog.text + async def test_fetch_period_api_with_minimal_response( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator @@ -713,12 +472,15 @@ async def test_fetch_period_api_with_no_timestamp( """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) client = await hass_client() - response = await client.get("/api/history/period") + response = await client.get("/api/history/period?filter_entity_id=sensor.power") assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_include_order( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the fetch period view for history.""" await async_setup_component( @@ -738,118 +500,8 @@ async def test_fetch_period_api_with_include_order( ) assert response.status == HTTPStatus.OK - -async def test_fetch_period_api_with_entity_glob_include( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "include": {"entity_globs": ["light.k*"]}, - } - }, - ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert response_json[0][0]["entity_id"] == "light.kitchen" - - -async def test_fetch_period_api_with_entity_glob_exclude( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "exclude": { - "entity_globs": ["light.k*", "binary_sensor.*_?"], - "domains": "switch", - "entities": "media_player.test", - }, - } - }, - ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.sensor_l", "on") - hass.states.async_set("binary_sensor.sensor_r", "on") - hass.states.async_set("binary_sensor.sensor", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 3 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} - - -async def test_fetch_period_api_with_entity_glob_include_and_exclude( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "exclude": { - "entity_globs": ["light.many*", "binary_sensor.*"], - }, - "include": { - "entity_globs": ["light.m*"], - "domains": "switch", - "entities": "media_player.test", - }, - } - }, - ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("light.many_state_changes", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.exclude", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 4 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == { - "light.many_state_changes", - "light.match", - "media_player.test", - "switch.match", - } + assert "The 'use_include_order' option is deprecated" in caplog.text + assert "The 'include' option is deprecated" in caplog.text async def test_entity_ids_limit_via_api( @@ -910,3 +562,217 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( assert len(response_json) == 2 assert response_json[0][0]["entity_id"] == "light.kitchen" assert response_json[1][0]["entity_id"] == "light.cow" + + +async def test_fetch_period_api_before_history_started( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the fetch period view for history for the far past.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_wait_recording_done(hass) + far_past = dt_util.utcnow() - timedelta(days=365) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{far_past.isoformat()}?filter_entity_id=light.kitchen", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert response_json == [] + + +async def test_fetch_period_api_far_future( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the fetch period view for history for the far future.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_wait_recording_done(hass) + far_future = dt_util.utcnow() + timedelta(days=365) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{far_future.isoformat()}?filter_entity_id=light.kitchen", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert response_json == [] + + +async def test_fetch_period_api_with_invalid_datetime( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the fetch period view for history with an invalid date time.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_wait_recording_done(hass) + client = await hass_client() + response = await client.get( + "/api/history/period/INVALID?filter_entity_id=light.kitchen", + ) + assert response.status == HTTPStatus.BAD_REQUEST + response_json = await response.json() + assert response_json == {"message": "Invalid datetime"} + + +async def test_fetch_period_api_invalid_end_time( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the fetch period view for history with an invalid end time.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_wait_recording_done(hass) + far_past = dt_util.utcnow() - timedelta(days=365) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{far_past.isoformat()}", + params={"filter_entity_id": "light.kitchen", "end_time": "INVALID"}, + ) + assert response.status == HTTPStatus.BAD_REQUEST + response_json = await response.json() + assert response_json == {"message": "Invalid end_time"} + + +async def test_entity_ids_limit_via_api_with_end_time( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test limiting history to entity_ids with end_time.""" + await async_setup_component( + hass, + "history", + {"history": {}}, + ) + start = dt_util.utcnow() + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") + + await async_wait_recording_done(hass) + + end_time = start + timedelta(minutes=1) + future_second = dt_util.utcnow() + timedelta(seconds=1) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{future_second.isoformat()}", + params={ + "filter_entity_id": "light.kitchen,light.cow", + "end_time": end_time.isoformat(), + }, + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 0 + + when = start - timedelta(minutes=1) + response = await client.get( + f"/api/history/period/{when.isoformat()}", + params={ + "filter_entity_id": "light.kitchen,light.cow", + "end_time": end_time.isoformat(), + }, + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.kitchen" + assert response_json[1][0]["entity_id"] == "light.cow" + + +async def test_fetch_period_api_with_no_entity_ids( + recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the fetch period view for history with minimal_response.""" + await async_setup_component(hass, "history", {}) + await async_wait_recording_done(hass) + + yesterday = dt_util.utcnow() - timedelta(days=1) + + client = await hass_client() + response = await client.get(f"/api/history/period/{yesterday.isoformat()}") + assert response.status == HTTPStatus.BAD_REQUEST + response_json = await response.json() + assert response_json == {"message": "filter_entity_id is missing"} + + +@pytest.mark.parametrize( + ("filter_entity_id", "status_code", "response_contains1", "response_contains2"), + [ + ("light.kitchen,light.cow", HTTPStatus.OK, "light.kitchen", "light.cow"), + ( + "light.kitchen,light.cow&", + HTTPStatus.BAD_REQUEST, + "message", + "Invalid filter_entity_id", + ), + ( + "light.kitchen,li-ght.cow", + HTTPStatus.BAD_REQUEST, + "message", + "Invalid filter_entity_id", + ), + ( + "light.kit!chen", + HTTPStatus.BAD_REQUEST, + "message", + "Invalid filter_entity_id", + ), + ( + "lig+ht.kitchen,light.cow", + HTTPStatus.BAD_REQUEST, + "message", + "Invalid filter_entity_id", + ), + ( + "light.kitchenlight.cow", + HTTPStatus.BAD_REQUEST, + "message", + "Invalid filter_entity_id", + ), + ("cow", HTTPStatus.BAD_REQUEST, "message", "Invalid filter_entity_id"), + ], +) +async def test_history_with_invalid_entity_ids( + filter_entity_id, + status_code, + response_contains1, + response_contains2, + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test sending valid and invalid entity_ids to the API.""" + await async_setup_component( + hass, + "history", + {"history": {}}, + ) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + + await async_wait_recording_done(hass) + now = dt_util.utcnow().isoformat() + client = await hass_client() + + response = await client.get( + f"/api/history/period/{now}", + params={"filter_entity_id": filter_entity_id}, + ) + assert response.status == status_code + response_json = await response.json() + assert response_contains1 in str(response_json) + assert response_contains2 in str(response_json) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 7668d6794d9..7f3d8c76aed 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -4,21 +4,15 @@ from __future__ import annotations # pylint: disable=invalid-name from datetime import timedelta from http import HTTPStatus -import importlib import json -import sys from unittest.mock import patch, sentinel import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import Session -from homeassistant.components import history, recorder -from homeassistant.components.recorder import Recorder, core, statistics +from homeassistant.components import recorder +from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.models import process_timestamp -from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -31,57 +25,16 @@ from tests.components.recorder.common import ( assert_states_equal_without_context, async_recorder_block_till_done, async_wait_recording_done, + old_db_schema, wait_recording_done, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator -CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_30" - - -def _create_engine_test(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION - ) - ) - session.commit() - return engine - @pytest.fixture(autouse=True) def db_schema_30(): - """Fixture to initialize the db with the old schema.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( - core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( - core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( - core, "StateAttributes", old_db_schema.StateAttributes - ), patch( - CREATE_ENGINE_TARGET, new=_create_engine_test - ): + """Fixture to initialize the db with the old schema 30.""" + with old_db_schema("30"): yield @@ -108,7 +61,7 @@ def test_get_significant_states(legacy_hass_history) -> None: """ hass = legacy_hass_history zero, four, states = record_states(hass) - hist = get_significant_states(hass, zero, four, filters=history.Filters()) + hist = get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -125,7 +78,7 @@ def test_get_significant_states_minimal_response(legacy_hass_history) -> None: hass = legacy_hass_history zero, four, states = record_states(hass) hist = get_significant_states( - hass, zero, four, filters=history.Filters(), minimal_response=True + hass, zero, four, minimal_response=True, entity_ids=list(states) ) entites_with_reducable_states = [ "media_player.test", @@ -202,8 +155,8 @@ def test_get_significant_states_with_initial(legacy_hass_history) -> None: hass, one_and_half, four, - filters=history.Filters(), include_start_time_state=True, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -234,8 +187,8 @@ def test_get_significant_states_without_initial(legacy_hass_history) -> None: hass, one_and_half, four, - filters=history.Filters(), include_start_time_state=False, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -253,9 +206,7 @@ def test_get_significant_states_entity_id(hass_history) -> None: del states["thermostat.test2"] del states["script.can_cancel_this_one"] - hist = get_significant_states( - hass, zero, four, ["media_player.test"], filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, ["media_player.test"]) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -273,247 +224,10 @@ def test_get_significant_states_multiple_entity_ids(legacy_hass_history) -> None zero, four, ["media_player.test", "thermostat.test"], - filters=history.Filters(), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_exclude_domain(legacy_hass_history) -> None: - """Test if significant states are returned when excluding domains. - - We should get back every thermostat change that includes an attribute - change, but no media player changes. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["media_player"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude_entity(legacy_hass_history) -> None: - """Test if significant states are returned when excluding entities. - - We should get back every thermostat and script changes, but no media - player changes. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: ["media_player.test"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude(legacy_hass_history) -> None: - """Test significant states when excluding entities and domains. - - We should not get back every thermostat and media player test changes. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["thermostat.test"] - del states["thermostat.test2"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - } - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_exclude_include_entity(legacy_hass_history) -> None: - """Test significant states when excluding domains and include entities. - - We should not get back every thermostat change unless its specifically included - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["thermostat.test2"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test", "thermostat.test"]}, - CONF_EXCLUDE: {CONF_DOMAINS: ["thermostat"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_domain(legacy_hass_history) -> None: - """Test if significant states are returned when including domains. - - We should get back every thermostat and script changes, but no media - player changes. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_INCLUDE: {CONF_DOMAINS: ["thermostat", "script"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_entity(legacy_hass_history) -> None: - """Test if significant states are returned when including entities. - - We should only get back changes of the media_player.test entity. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: {CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test"]}}, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include(legacy_hass_history) -> None: - """Test significant states when including domains and entities. - - We should only get back changes of the media_player.test entity and the - thermostat domain. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - } - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude_domain(legacy_hass_history) -> None: - """Test if significant states when excluding and including domains. - - We should get back all the media_player domain changes - only since the include wins over the exclude but will - exclude everything else. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_DOMAINS: ["media_player"]}, - CONF_EXCLUDE: {CONF_DOMAINS: ["media_player"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude_entity(legacy_hass_history) -> None: - """Test if significant states when excluding and including domains. - - We should not get back any changes since we include only - media_player.test but also exclude it. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: {CONF_ENTITIES: ["media_player.test"]}, - CONF_EXCLUDE: {CONF_ENTITIES: ["media_player.test"]}, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - -def test_get_significant_states_include_exclude(legacy_hass_history) -> None: - """Test if significant states when in/excluding domains and entities. - - We should get back changes of the media_player.test2, media_player.test3, - and thermostat.test. - """ - hass = legacy_hass_history - zero, four, states = record_states(hass) - del states["media_player.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - config = history.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - history.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["media_player"], - CONF_ENTITIES: ["thermostat.test"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["thermostat"], - CONF_ENTITIES: ["media_player.test"], - }, - }, - } - ) - check_significant_states(hass, zero, four, states, config) - - def test_get_significant_states_are_ordered(legacy_hass_history) -> None: """Test order of results from get_significant_states. @@ -523,14 +237,10 @@ def test_get_significant_states_are_ordered(legacy_hass_history) -> None: hass = legacy_hass_history zero, four, _states = record_states(hass) entity_ids = ["media_player.test", "media_player.test2"] - hist = get_significant_states( - hass, zero, four, entity_ids, filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids entity_ids = ["media_player.test2", "media_player.test"] - hist = get_significant_states( - hass, zero, four, entity_ids, filters=history.Filters() - ) + hist = get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -577,7 +287,12 @@ def test_get_significant_states_only(legacy_hass_history) -> None: # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) - hist = get_significant_states(hass, start, significant_changes_only=True) + hist = get_significant_states( + hass, + start, + significant_changes_only=True, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 2 assert not any( @@ -590,7 +305,12 @@ def test_get_significant_states_only(legacy_hass_history) -> None: state.last_updated == states[2].last_updated for state in hist[entity_id] ) - hist = get_significant_states(hass, start, significant_changes_only=False) + hist = get_significant_states( + hass, + start, + significant_changes_only=False, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 3 assert_multiple_states_equal_without_context_and_last_changed( @@ -600,16 +320,7 @@ def test_get_significant_states_only(legacy_hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - domain_config = config[history.DOMAIN] - exclude = domain_config.get(CONF_EXCLUDE, {}) - include = domain_config.get(CONF_INCLUDE, {}) - filters = history.Filters( - excluded_entities=exclude.get(CONF_ENTITIES, []), - excluded_domains=exclude.get(CONF_DOMAINS, []), - included_entities=include.get(CONF_ENTITIES, []), - included_domains=include.get(CONF_DOMAINS, []), - ) - hist = get_significant_states(hass, zero, four, filters=filters) + hist = get_significant_states(hass, zero, four) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -707,23 +418,7 @@ async def test_fetch_period_api( with patch.object(instance.states_meta_manager, "active", False): client = await hass_client() response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}" - ) - assert response.status == HTTPStatus.OK - - -async def test_fetch_period_api_with_use_include_order( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history with include order.""" - await async_setup_component( - hass, "history", {history.DOMAIN: {history.CONF_ORDER: True}} - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}" + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=sensor.power" ) assert response.status == HTTPStatus.OK @@ -779,7 +474,7 @@ async def test_fetch_period_api_with_no_timestamp( instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): client = await hass_client() - response = await client.get("/api/history/period") + response = await client.get("/api/history/period?filter_entity_id=sensor.power") assert response.status == HTTPStatus.OK @@ -807,125 +502,6 @@ async def test_fetch_period_api_with_include_order( assert response.status == HTTPStatus.OK -async def test_fetch_period_api_with_entity_glob_include( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "include": {"entity_globs": ["light.k*"]}, - } - }, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert response_json[0][0]["entity_id"] == "light.kitchen" - - -async def test_fetch_period_api_with_entity_glob_exclude( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "exclude": { - "entity_globs": ["light.k*", "binary_sensor.*_?"], - "domains": "switch", - "entities": "media_player.test", - }, - } - }, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.sensor_l", "on") - hass.states.async_set("binary_sensor.sensor_r", "on") - hass.states.async_set("binary_sensor.sensor", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 3 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} - - -async def test_fetch_period_api_with_entity_glob_include_and_exclude( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "exclude": { - "entity_globs": ["light.many*", "binary_sensor.*"], - }, - "include": { - "entity_globs": ["light.m*"], - "domains": "switch", - "entities": "media_player.test", - }, - } - }, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("light.many_state_changes", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.exclude", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 4 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == { - "light.many_state_changes", - "light.match", - "media_player.test", - "switch.match", - } - - async def test_entity_ids_limit_via_api( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -1402,6 +978,7 @@ async def test_history_during_period_bad_start_time( { "id": 1, "type": "history/history_during_period", + "entity_ids": ["sensor.pet"], "start_time": "cats", } ) @@ -1428,6 +1005,7 @@ async def test_history_during_period_bad_end_time( { "id": 1, "type": "history/history_during_period", + "entity_ids": ["sensor.pet"], "start_time": now.isoformat(), "end_time": "dogs", } diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index a30d6329f65..f8d4ec7d9f7 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -10,12 +10,7 @@ import pytest from homeassistant.components import history from homeassistant.components.history import websocket_api from homeassistant.components.recorder import Recorder -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_INCLUDE, - EVENT_HOMEASSISTANT_FINAL_WRITE, -) +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -422,6 +417,7 @@ async def test_history_during_period_bad_start_time( { "id": 1, "type": "history/history_during_period", + "entity_ids": ["sensor.pet"], "start_time": "cats", } ) @@ -447,6 +443,7 @@ async def test_history_during_period_bad_end_time( { "id": 1, "type": "history/history_during_period", + "entity_ids": ["sensor.pet"], "start_time": now.isoformat(), "end_time": "dogs", } @@ -461,19 +458,10 @@ async def test_history_stream_historical_only( ) -> None: """Test history stream.""" now = dt_util.utcnow() - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -500,6 +488,7 @@ async def test_history_stream_historical_only( { "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, @@ -755,6 +744,7 @@ async def test_history_stream_bad_start_time( { "id": 1, "type": "history/stream", + "entity_ids": ["climate.test"], "start_time": "cats", } ) @@ -781,6 +771,7 @@ async def test_history_stream_end_time_before_start_time( { "id": 1, "type": "history/stream", + "entity_ids": ["climate.test"], "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), } @@ -807,6 +798,7 @@ async def test_history_stream_bad_end_time( { "id": 1, "type": "history/stream", + "entity_ids": ["climate.test"], "start_time": now.isoformat(), "end_time": "dogs", } @@ -821,19 +813,10 @@ async def test_history_stream_live_no_attributes_minimal_response( ) -> None: """Test history stream with history and live data and no_attributes and minimal_response.""" now = dt_util.utcnow() - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -853,6 +836,7 @@ async def test_history_stream_live_no_attributes_minimal_response( { "id": 1, "type": "history/stream", + "entity_ids": ["sensor.one", "sensor.two"], "start_time": now.isoformat(), "include_start_time_state": True, "significant_changes_only": False, @@ -910,19 +894,10 @@ async def test_history_stream_live( ) -> None: """Test history stream with history and live data.""" now = dt_util.utcnow() - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -942,6 +917,7 @@ async def test_history_stream_live( { "id": 1, "type": "history/stream", + "entity_ids": ["sensor.one", "sensor.two"], "start_time": now.isoformat(), "include_start_time_state": True, "significant_changes_only": False, @@ -1021,19 +997,10 @@ async def test_history_stream_live_minimal_response( ) -> None: """Test history stream with history and live data and minimal_response.""" now = dt_util.utcnow() - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -1053,6 +1020,7 @@ async def test_history_stream_live_minimal_response( { "id": 1, "type": "history/stream", + "entity_ids": ["sensor.one", "sensor.two"], "start_time": now.isoformat(), "include_start_time_state": True, "significant_changes_only": False, @@ -1126,19 +1094,10 @@ async def test_history_stream_live_no_attributes( ) -> None: """Test history stream with history and live data and no_attributes.""" now = dt_util.utcnow() - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -1159,6 +1118,7 @@ async def test_history_stream_live_no_attributes( "id": 1, "type": "history/stream", "start_time": now.isoformat(), + "entity_ids": ["sensor.one", "sensor.two"], "include_start_time_state": True, "significant_changes_only": False, "no_attributes": True, @@ -1396,19 +1356,10 @@ async def test_history_stream_before_history_starts( include_start_time_state, ) -> None: """Test history stream before we have history.""" - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -1453,19 +1404,10 @@ async def test_history_stream_for_entity_with_no_possible_changes( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history stream for future with no possible changes where end time is less than or equal to now.""" - sort_order = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, "history", - { - history.DOMAIN: { - history.CONF_ORDER: True, - CONF_INCLUDE: { - CONF_ENTITIES: sort_order, - CONF_DOMAINS: ["sensor"], - }, - } - }, + {}, ) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -1584,3 +1526,320 @@ async def test_overflow_queue( assert listeners_without_writes( hass.bus.async_listeners() ) == listeners_without_writes(init_listeners) + + +async def test_history_during_period_for_invalid_entity_ids( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test history_during_period for valid and invalid entity ids.""" + now = dt_util.utcnow() + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) + sensor_one_last_updated = 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": "again"}) + await async_recorder_block_till_done(hass) + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response == { + "result": { + "sensor.one": [ + {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + ], + }, + "id": 1, + "type": "result", + "success": True, + } + + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one", "sensor.two"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response == { + "result": { + "sensor.one": [ + {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + ], + "sensor.two": [ + {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + ], + }, + "id": 2, + "type": "result", + "success": True, + } + + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sens!or.one", "two"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 3, + "type": "result", + "success": False, + } + + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one", "sensortwo."], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 4, + "type": "result", + "success": False, + } + + await client.send_json( + { + "id": 5, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["one", ".sensortwo"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 5, + "type": "result", + "success": False, + } + + +async def test_history_stream_for_invalid_entity_ids( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test history stream for invalid and valid entity ids.""" + + now = dt_util.utcnow() + await async_setup_component( + hass, + "history", + {history.DOMAIN: {}}, + ) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) + sensor_one_last_updated = 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": "again"}) + await async_recorder_block_till_done(hass) + await async_wait_recording_done(hass) + + await async_wait_recording_done(hass) + + client = await hass_ws_client() + + await client.send_json( + { + "id": 1, + "type": "history/stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one"], + "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_one_last_updated.timestamp(), + "start_time": now.timestamp(), + "states": { + "sensor.one": [ + {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + ], + }, + }, + "id": 1, + "type": "event", + } + + await client.send_json( + { + "id": 2, + "type": "history/stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one", "sensor.two"], + "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"] == 2 + assert response["type"] == "result" + + response = await client.receive_json() + assert response == { + "event": { + "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"} + ], + }, + }, + "id": 2, + "type": "event", + } + + await client.send_json( + { + "id": 3, + "type": "history/stream", + "start_time": now.isoformat(), + "entity_ids": ["sens!or.one", "two"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["id"] == 3 + assert response["type"] == "result" + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 3, + "type": "result", + "success": False, + } + + await client.send_json( + { + "id": 4, + "type": "history/stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.one", "sensortwo."], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["id"] == 4 + assert response["type"] == "result" + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 4, + "type": "result", + "success": False, + } + + await client.send_json( + { + "id": 5, + "type": "history/stream", + "start_time": now.isoformat(), + "entity_ids": ["one", ".sensortwo"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["id"] == 5 + assert response["type"] == "result" + assert response == { + "error": { + "code": "invalid_entity_ids", + "message": "Invalid entity_ids", + }, + "id": 5, + "type": "result", + "success": False, + } diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py new file mode 100644 index 00000000000..aebf5aa7ac2 --- /dev/null +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -0,0 +1,161 @@ +"""The tests the History component websocket_api.""" +# pylint: disable=protected-access,invalid-name + +import pytest + +from homeassistant.components import recorder +from homeassistant.components.recorder import Recorder +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.components.recorder.common import ( + async_recorder_block_till_done, + async_wait_recording_done, + old_db_schema, +) +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def db_schema_32(): + """Fixture to initialize the db with the old schema 32.""" + with old_db_schema("32"): + yield + + +async def test_history_during_period( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test history_during_period.""" + now = dt_util.utcnow() + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + recorder.get_instance(hass).states_meta_manager.active = False + assert recorder.get_instance(hass).schema_version == 32 + + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_wait_recording_done(hass) + + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "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"] == 2 + + sensor_test_history = response["result"]["sensor.test"] + assert len(sensor_test_history) == 3 + + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {} + assert isinstance(sensor_test_history[0]["lu"], float) + assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + + assert "a" not in sensor_test_history[1] + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + + assert sensor_test_history[2]["s"] == "on" + assert "a" not in sensor_test_history[2] + + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 3 + sensor_test_history = response["result"]["sensor.test"] + + assert len(sensor_test_history) == 5 + + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} + + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"any": "attr"} + + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 4 + sensor_test_history = response["result"]["sensor.test"] + + assert len(sensor_test_history) == 3 + + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} + + assert sensor_test_history[2]["s"] == "on" + assert sensor_test_history[2]["a"] == {"any": "attr"} diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 4d705fefcc4..141c0adb68f 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1,5 +1,5 @@ """The test for the History Statistics sensor platform.""" -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time @@ -20,6 +20,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path +from tests.components.recorder.common import async_wait_recording_done +from tests.typing import RecorderInstanceGenerator async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -1367,13 +1369,20 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) async def test_end_time_with_microseconds_zeroed( - time_zone, recorder_mock: Recorder, hass: HomeAssistant + time_zone: str, + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, ) -> None: """Test the history statistics sensor that has the end time microseconds zeroed out.""" hass.config.set_time_zone(time_zone) start_of_today = dt_util.now().replace( day=9, month=7, year=1986, hour=0, minute=0, second=0, microsecond=0 ) + with freeze_time(start_of_today): + await async_setup_recorder_instance(hass) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + start_time = start_of_today + timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) t1 = t0 + timedelta(minutes=10) @@ -1434,7 +1443,7 @@ async def test_end_time_with_microseconds_zeroed( await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" hass.states.async_set("binary_sensor.heatpump_compressor_state", "on") - await hass.async_block_till_done() + await async_wait_recording_done(hass) time_600 = start_of_today + timedelta(hours=6) with freeze_time(time_600): async_fire_time_changed(hass, time_600) @@ -1473,6 +1482,7 @@ async def test_end_time_with_microseconds_zeroed( ) with freeze_time(rolled_to_next_day_plus_16_860000): hass.states.async_set("binary_sensor.heatpump_compressor_state", "off") + await async_wait_recording_done(hass) async_fire_time_changed(hass, rolled_to_next_day_plus_16_860000) await hass.async_block_till_done() @@ -1524,3 +1534,58 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N assert hass.states.get("sensor.time").attributes[ATTR_DEVICE_CLASS] == "duration" assert ATTR_DEVICE_CLASS not in hass.states.get("sensor.ratio").attributes assert ATTR_DEVICE_CLASS not in hass.states.get("sensor.count").attributes + + +async def test_history_stats_handles_floored_timestamps( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test we account for microseconds when doing the data calculation.""" + hass.config.set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + last_times = None + + def _fake_states( + hass: HomeAssistant, start: datetime, end: datetime | None, *args, **kwargs + ) -> dict[str, list[ha.State]]: + """Fake state changes.""" + nonlocal last_times + last_times = (start, end) + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0, microsecond=100) }}", + "duration": {"hours": 2}, + "type": "time", + } + ] + }, + ) + await hass.async_block_till_done() + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert last_times == (start_time, start_time + timedelta(hours=2)) diff --git a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr new file mode 100644 index 00000000000..2f9d0b8017f --- /dev/null +++ b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_assistant_settings + dict({ + 'climate.test_unique1': mappingproxy({ + 'should_expose': True, + }), + 'light.not_in_registry': dict({ + 'should_expose': True, + }), + }) +# --- +# name: test_get_assistant_settings.1 + dict({ + }) +# --- +# name: test_listeners + dict({ + 'light.kitchen': dict({ + 'should_expose': True, + }), + 'switch.test_unique1': mappingproxy({ + 'should_expose': True, + }), + }) +# --- diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py new file mode 100644 index 00000000000..fd09bcee45a --- /dev/null +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -0,0 +1,547 @@ +"""Test Home Assistant exposed entities helper.""" +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + ExposedEntity, + async_expose_entity, + async_get_assistant_settings, + async_get_entity_settings, + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import flush_store +from tests.typing import WebSocketGenerator + + +@pytest.fixture(name="entities") +def entities_fixture( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + request: pytest.FixtureRequest, +) -> dict[str, str]: + """Set up the test environment.""" + if request.param == "entities_unique_id": + return entities_unique_id(entity_registry) + elif request.param == "entities_no_unique_id": + return entities_no_unique_id(hass) + else: + raise RuntimeError("Invalid setup fixture") + + +def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: + """Create some entities in the entity registry.""" + entry_blocked = entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + entry_lock = entity_registry.async_get_or_create("lock", "test", "unique1") + entry_binary_sensor = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique1" + ) + entry_binary_sensor_door = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "unique2", + original_device_class="door", + ) + entry_sensor = entity_registry.async_get_or_create("sensor", "test", "unique1") + entry_sensor_temperature = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + original_device_class="temperature", + ) + return { + "blocked": entry_blocked.entity_id, + "lock": entry_lock.entity_id, + "binary_sensor": entry_binary_sensor.entity_id, + "door_sensor": entry_binary_sensor_door.entity_id, + "sensor": entry_sensor.entity_id, + "temperature_sensor": entry_sensor_temperature.entity_id, + } + + +def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: + """Create some entities not in the entity registry.""" + blocked = CLOUD_NEVER_EXPOSED_ENTITIES[0] + lock = "lock.test" + binary_sensor = "binary_sensor.test" + door_sensor = "binary_sensor.door" + sensor = "sensor.test" + sensor_temperature = "sensor.temperature" + hass.states.async_set(binary_sensor, "on", {}) + hass.states.async_set(door_sensor, "on", {"device_class": "door"}) + hass.states.async_set(sensor, "on", {}) + hass.states.async_set(sensor_temperature, "on", {"device_class": "temperature"}) + return { + "blocked": blocked, + "lock": lock, + "binary_sensor": binary_sensor, + "door_sensor": door_sensor, + "sensor": sensor, + "temperature_sensor": sensor_temperature, + } + + +async def test_load_preferences(hass: HomeAssistant) -> None: + """Make sure that we can load/save data correctly.""" + assert await async_setup_component(hass, "homeassistant", {}) + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert exposed_entities._assistants == {} + + exposed_entities.async_set_expose_new_entities("test1", True) + exposed_entities.async_set_expose_new_entities("test2", False) + + async_expose_entity(hass, "test1", "light.kitchen", True) + async_expose_entity(hass, "test1", "light.living_room", True) + async_expose_entity(hass, "test2", "light.kitchen", True) + async_expose_entity(hass, "test2", "light.kitchen", True) + + assert list(exposed_entities._assistants) == ["test1", "test2"] + assert list(exposed_entities.entities) == ["light.kitchen", "light.living_room"] + + await flush_store(exposed_entities._store) + + exposed_entities2 = ExposedEntities(hass) + await exposed_entities2.async_initialize() + + assert exposed_entities._assistants == exposed_entities2._assistants + assert exposed_entities.entities == exposed_entities2.entities + + +async def test_expose_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert len(exposed_entities.entities) == 0 + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry1.entity_id], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == {"cloud.alexa": {"should_expose": True}} + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == {} + assert len(exposed_entities.entities) == 0 + + # Update options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry1.entity_id, entry2.entity_id], + "should_expose": False, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + assert len(exposed_entities.entities) == 0 + + +async def test_expose_entity_unknown( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing an unknown entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert len(exposed_entities.entities) == 0 + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + assert len(exposed_entities.entities) == 1 + assert exposed_entities.entities == { + "test.test": ExposedEntity({"cloud.alexa": {"should_expose": True}}) + } + + # Update options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test", "test.test2"], + "should_expose": False, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + assert len(exposed_entities.entities) == 2 + assert exposed_entities.entities == { + "test.test": ExposedEntity( + { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + ), + "test.test2": ExposedEntity( + { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + ), + } + + +async def test_expose_entity_blocked( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing a blocked entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["group.all_locks"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_allowed", + "message": "can't expose 'group.all_locks'", + } + + +@pytest.mark.parametrize("expose_new", [True, False]) +async def test_expose_new_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + expose_new, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("climate", "test", "unique1") + entry2 = entity_registry.async_get_or_create("climate", "test", "unique2") + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": False} + + # Check if exposed - should be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": expose_new, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": expose_new} + + # Check again if exposed - should still be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Check if exposed - should be True + assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new + + +async def test_listen_updates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test listen to updates.""" + calls = [] + + def listener(): + calls.append(None) + + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + async_listen_entity_updates(hass, "cloud.alexa", listener) + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + # Call for another assistant - listener not called + async_expose_entity(hass, "cloud.google_assistant", entry.entity_id, True) + assert len(calls) == 0 + + # Call for our assistant - listener called + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings not changed - listener not called + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings changed - listener called + async_expose_entity(hass, "cloud.alexa", entry.entity_id, False) + assert len(calls) == 2 + + +async def test_get_assistant_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test get assistant settings.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + assert async_get_assistant_settings(hass, "cloud.alexa") == {} + + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) + async_expose_entity(hass, "cloud.alexa", "light.not_in_registry", True) + assert async_get_assistant_settings(hass, "cloud.alexa") == snapshot + assert async_get_assistant_settings(hass, "cloud.google_assistant") == snapshot + + with pytest.raises(HomeAssistantError): + async_get_entity_settings(hass, "light.unknown") + + +@pytest.mark.parametrize( + "entities", ["entities_unique_id", "entities_no_unique_id"], indirect=True +) +async def test_should_expose( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + entities: dict[str, str], +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Unknown entity is not exposed + assert async_should_expose(hass, "test.test", "test.test") is False + + # Blocked entity is not exposed + assert async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False + + # Lock is exposed + assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is True + + # Binary sensor without device class is not exposed + assert async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) is False + + # Binary sensor with certain device class is exposed + assert async_should_expose(hass, "cloud.alexa", entities["door_sensor"]) is True + + # Sensor without device class is not exposed + assert async_should_expose(hass, "cloud.alexa", entities["sensor"]) is False + + # Sensor with certain device class is exposed + assert ( + async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True + ) + + # The second time we check, it should load it from storage + assert ( + async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True + ) + + # Check with a different assistant + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.no_default_expose", False) + assert ( + async_should_expose( + hass, "cloud.no_default_expose", entities["temperature_sensor"] + ) + is False + ) + + +async def test_should_expose_hidden_categorized( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + entity_registry.async_get_or_create( + "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER + ) + assert async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False + + # Entity with category is not exposed + entity_registry.async_get_or_create( + "lock", "test", "unique3", entity_category=EntityCategory.CONFIG + ) + assert async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False + + +async def test_list_exposed_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test list exposed entities.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Set options for registered entities + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry1.entity_id, entry2.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Set options for entities not in the entity registry + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [ + "test.test", + "test.test2", + ], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # List exposed entities + await ws_client.send_json_auto_id({"type": "homeassistant/expose_entity/list"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, + "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, + }, + } + + +async def test_listeners( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Make sure we call entity listeners.""" + assert await async_setup_component(hass, "homeassistant", {}) + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + callbacks = [] + exposed_entities.async_listen_entity_updates("test1", lambda: callbacks.append(1)) + + async_expose_entity(hass, "test1", "light.kitchen", True) + assert len(callbacks) == 1 + + entry1 = entity_registry.async_get_or_create("switch", "test", "unique1") + async_expose_entity(hass, "test1", entry1.entity_id, True) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 53d1c5e974d..66401bcd7bc 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Yellow config flow.""" from unittest.mock import Mock, patch +import pytest + from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN from homeassistant.core import HomeAssistant @@ -9,6 +11,34 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_yellow_settings") +def mock_get_yellow_settings(): + """Mock getting yellow settings.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings", + return_value={"disk_led": True, "heartbeat_led": True, "power_led": True}, + ) as get_yellow_settings: + yield get_yellow_settings + + +@pytest.fixture(name="set_yellow_settings") +def mock_set_yellow_settings(): + """Mock setting yellow settings.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings", + ) as set_yellow_settings: + yield set_yellow_settings + + +@pytest.fixture(name="reboot_host") +def mock_reboot_host(): + """Mock rebooting host.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_reboot_host", + ) as reboot_host: + yield reboot_host + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -79,11 +109,17 @@ async def test_option_flow_install_multi_pan_addon( ) config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + 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) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "multipan_settings"}, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "addon_not_installed" @@ -155,11 +191,17 @@ async def test_option_flow_install_multi_pan_addon_zha( ) zha_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + 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) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "multipan_settings"}, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "addon_not_installed" @@ -210,3 +252,156 @@ async def test_option_flow_install_multi_pan_addon_zha( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("reboot_menu_choice", "reboot_calls"), + [("reboot_now", 1), ("reboot_later", 0)], +) +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_yellow_settings, + set_yellow_settings, + reboot_host, + reboot_menu_choice, + reboot_calls, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": False, "heartbeat_led": False, "power_led": False}, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reboot_menu" + set_yellow_settings.assert_called_once_with( + hass, {"disk_led": False, "heartbeat_led": False, "power_led": False} + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": reboot_menu_choice}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert len(reboot_host.mock_calls) == reboot_calls + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_yellow_settings, + set_yellow_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": True, "heartbeat_led": True, "power_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_yellow_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_yellow_settings +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": False, "heartbeat_led": False, "power_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error" diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 64a44cd38a9..18e654cb4ed 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -2,7 +2,7 @@ import os from unittest.mock import patch -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from homeassistant.components.homekit.aidmanager import ( AccessoryAidStorage, @@ -386,7 +386,7 @@ async def test_aid_generation_no_unique_ids_handles_collision( await aid_storage.async_save() await hass.async_block_till_done() - with patch("fnvhash.fnv1a_32", side_effect=Exception): + with patch("fnv_hash_fast.fnv1a_32", side_effect=Exception): aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 3de10491f39..b925fcb341c 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -427,6 +427,7 @@ async def test_options_flow_devices( demo_config_entry = MockConfigEntry(domain="domain") demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) assert await async_setup_component(hass, "homekit", {"homekit": {}}) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index 58babc0ccb0..69e2aa2c8e2 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -319,6 +319,7 @@ async def test_config_entry_with_trigger_accessory( entity_registry: er.EntityRegistry, ) -> None: """Test generating diagnostics for a bridge config entry with a trigger accessory.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) hk_driver.publish = MagicMock() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index a6dbdbebdd4..0b74763c6a7 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -164,12 +164,12 @@ async def test_setup_min(hass: HomeAssistant, mock_async_zeroconf: None) -> None hass, BRIDGE_NAME, DEFAULT_PORT, - "1.2.3.4", + [None], ANY, ANY, {}, HOMEKIT_MODE_BRIDGE, - None, + "1.2.3.4", entry.entry_id, entry.title, devices=[], @@ -206,12 +206,12 @@ async def test_removing_entry( hass, BRIDGE_NAME, DEFAULT_PORT, - "1.2.3.4", + [None], ANY, ANY, {}, HOMEKIT_MODE_BRIDGE, - None, + "1.2.3.4", entry.entry_id, entry.title, devices=[], @@ -747,6 +747,7 @@ async def test_homekit_start_with_a_device( entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() @@ -815,14 +816,10 @@ async def test_homekit_reset_accessories( homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( - "pyhap.accessory.Bridge.add_accessory" - ) as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.async_start" - ), patch( + ), patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" - ), patch.object( + ) as mock_run_accessory, patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) @@ -837,8 +834,9 @@ async def test_homekit_reset_accessories( blocking=True, ) await hass.async_block_till_done() + await hass.async_block_till_done() - assert mock_add_accessory.called + assert mock_run_accessory.called homekit.status = STATUS_READY await homekit.async_stop() @@ -1479,12 +1477,12 @@ async def test_yaml_updates_update_config_entry_for_name( hass, BRIDGE_NAME, 12345, - "1.2.3.4", + [None], ANY, ANY, {}, HOMEKIT_MODE_BRIDGE, - None, + "1.2.3.4", entry.entry_id, entry.title, devices=[], @@ -1837,12 +1835,12 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: hass, "reloadable", 12345, - "1.2.3.4", + [None], ANY, False, {}, HOMEKIT_MODE_BRIDGE, - None, + "1.2.3.4", entry.entry_id, entry.title, devices=[], @@ -1872,12 +1870,12 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: hass, "reloadable", 45678, - "1.2.3.4", + [None], ANY, False, {}, HOMEKIT_MODE_BRIDGE, - None, + "1.2.3.4", entry.entry_id, entry.title, devices=[], diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 2bb9a4972a3..5a1d42352fe 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -81,6 +81,7 @@ async def test_bridge_with_triggers( an above or below additional configuration which we have no way to input, we ignore them. """ + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 645452363e0..9fcd36d06f3 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -19,11 +19,13 @@ from homeassistant.components.homekit.const import ( CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES, SERV_DOORBELL, SERV_MOTION_SENSOR, SERV_STATELESS_PROGRAMMABLE_SWITCH, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, + VIDEO_CODEC_H264_V4L2M2M, ) from homeassistant.components.homekit.type_cameras import Camera from homeassistant.components.homekit.type_switches import Switch @@ -41,6 +43,12 @@ MOCK_START_STREAM_SESSION_UUID = UUID("3303d503-17cc-469a-b672-92436a71a2f6") PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647 +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def _async_start_streaming(hass, acc): """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) @@ -516,6 +524,79 @@ async def test_camera_stream_source_configured_and_copy_codec( ) +async def test_camera_stream_source_configured_and_override_profile_names( + hass: HomeAssistant, run_driver, events +) -> None: + """Test a camera that can stream with a configured source over overridden profile names.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + + entity_id = "camera.demo_camera" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + { + CONF_STREAM_SOURCE: "/dev/null", + CONF_SUPPORT_AUDIO: True, + CONF_VIDEO_CODEC: VIDEO_CODEC_H264_V4L2M2M, + CONF_VIDEO_PROFILE_NAMES: ["0", "2", "4"], + CONF_AUDIO_CODEC: AUDIO_CODEC_COPY, + }, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + await _async_setup_endpoints(hass, acc) + session_info = acc.sessions[MOCK_START_STREAM_SESSION_UUID] + + working_ffmpeg = _get_working_mock_ffmpeg() + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=None, + ), patch( + "homeassistant.components.homekit.type_cameras.HAFFmpeg", + return_value=working_ffmpeg, + ): + await _async_start_streaming(hass, acc) + await _async_reconfigure_stream(hass, acc, session_info, {}) + await _async_stop_all_streams(hass, acc) + + expected_output = ( + "-map 0:v:0 -an -c:v h264_v4l2m2m -profile:v 4 -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " + "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " + "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " + "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " + "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " + "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + ) + + working_ffmpeg.open.assert_called_with( + cmd=[], + input_source="-i /dev/null", + output=expected_output.format(**session_info), + stdout_pipe=False, + extra_cmd="-hide_banner -nostats", + stderr_pipe=True, + ) + + async def test_camera_streaming_fails_after_starting_ffmpeg( hass: HomeAssistant, run_driver, events ) -> None: diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index 27580c05969..f3e4f96573d 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -286,7 +286,9 @@ async def test_hygrostat_power_state(hass: HomeAssistant, hk_driver, events) -> assert events[-1].data[ATTR_VALUE] == "Active to 0" -async def test_hygrostat_get_humidity_range(hass: HomeAssistant, hk_driver) -> None: +async def test_hygrostat_get_humidity_range( + hass: HomeAssistant, hk_driver, events +) -> None: """Test if humidity range is evaluated correctly.""" entity_id = "humidifier.test" @@ -302,8 +304,48 @@ async def test_hygrostat_get_humidity_range(hass: HomeAssistant, hk_driver) -> N await acc.run() await hass.async_block_till_done() - assert acc.char_target_humidity.properties[PROP_MAX_VALUE] == 45 - assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == 40 + # Set from HomeKit + call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + + char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidity_iid, + HAP_REPR_VALUE: 12.0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_humidity[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_humidity[-1].data[ATTR_HUMIDITY] == 40.0 + assert acc.char_target_humidity.value == 40.0 + assert events[-1].data[ATTR_VALUE] == "RelativeHumidityHumidifierThreshold to 12.0%" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidity_iid, + HAP_REPR_VALUE: 80.0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_humidity[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_humidity[-1].data[ATTR_HUMIDITY] == 45.0 + assert acc.char_target_humidity.value == 45.0 + assert events[-1].data[ATTR_VALUE] == "RelativeHumidityHumidifierThreshold to 80.0%" async def test_humidifier_with_linked_humidity_sensor( diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index fd77499ff09..0374f3f1e94 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -25,6 +25,7 @@ async def test_programmable_switch_button_fires_on_trigger( demo_config_entry = MockConfigEntry(domain="domain") demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() hass.states.async_set("light.ceiling_lights", STATE_OFF) diff --git a/tests/components/honeywell/__init__.py b/tests/components/honeywell/__init__.py index 7c6b4ca78c6..6299097b104 100644 --- a/tests/components/honeywell/__init__.py +++ b/tests/components/honeywell/__init__.py @@ -1 +1,25 @@ """Tests for honeywell component.""" +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Honeywell integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def reset_mock(device: MagicMock) -> None: + """Reset the mocks for test.""" + device.set_setpoint_cool.reset_mock() + device.set_setpoint_heat.reset_mock() + device.set_hold_heat.reset_mock() + device.set_hold_cool.reset_mock() diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 95e1758ec22..bedd4290944 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -5,25 +5,53 @@ from unittest.mock import AsyncMock, create_autospec, patch import aiosomecomfort import pytest -from homeassistant.components.honeywell.const import DOMAIN +from homeassistant.components.honeywell.const import ( + CONF_COOL_AWAY_TEMPERATURE, + CONF_HEAT_AWAY_TEMPERATURE, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry +HEATUPPERSETPOINTLIMIT = 35 +HEATLOWERSETPOINTLIMIT = 20 +COOLUPPERSETPOINTLIMIT = 20 +COOLLOWERSETPOINTLIMIT = 10 +NEXTCOOLPERIOD = 10 +NEXTHEATPERIOD = 10 +OUTDOORTEMP = 5 +OUTDOORHUMIDITY = 25 +CURRENTTEMPERATURE = 20 +CURRENTHUMIDITY = 50 +HEATAWAY = 10 +COOLAWAY = 20 +SETPOINTCOOL = 26 +SETPOINTHEAT = 18 + @pytest.fixture def config_data(): """Provide configuration data for tests.""" - return {CONF_USERNAME: "fake", CONF_PASSWORD: "user"} + return { + CONF_USERNAME: "fake", + CONF_PASSWORD: "user", + } @pytest.fixture -def config_entry(config_data): +def config_options(): + """Provide configuratio options for test.""" + return {CONF_COOL_AWAY_TEMPERATURE: 12, CONF_HEAT_AWAY_TEMPERATURE: 22} + + +@pytest.fixture +def config_entry(config_data, config_options): """Create a mock config entry.""" return MockConfigEntry( domain=DOMAIN, data=config_data, - options={}, + options=config_options, ) @@ -33,15 +61,53 @@ def device(): mock_device = create_autospec(aiosomecomfort.device.Device, instance=True) mock_device.deviceid = 1234567 mock_device._data = { - "canControlHumidification": False, - "hasFan": False, + "canControlHumidification": True, + "hasFan": True, } mock_device.system_mode = "off" mock_device.name = "device1" - mock_device.current_temperature = 20 + mock_device.current_temperature = CURRENTTEMPERATURE mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.is_alive = True + mock_device.fan_running = False + mock_device.fan_mode = "auto" + mock_device.setpoint_cool = SETPOINTCOOL + mock_device.setpoint_heat = SETPOINTHEAT + mock_device.hold_heat = False + mock_device.hold_cool = False + mock_device.current_humidity = CURRENTHUMIDITY + mock_device.equipment_status = "off" + mock_device.equipment_output_status = "off" + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + mock_device.set_setpoint_cool = AsyncMock() + mock_device.set_setpoint_heat = AsyncMock() + mock_device.set_system_mode = AsyncMock() + mock_device.set_fan_mode = AsyncMock() + mock_device.set_hold_heat = AsyncMock() + mock_device.set_hold_cool = AsyncMock() + mock_device.refresh = AsyncMock() + mock_device.heat_away_temp = HEATAWAY + mock_device.cool_away_temp = COOLAWAY + return mock_device @@ -56,11 +122,11 @@ def device_with_outdoor_sensor(): } mock_device.system_mode = "off" mock_device.name = "device1" - mock_device.current_temperature = 20 + mock_device.current_temperature = CURRENTTEMPERATURE mock_device.mac_address = "macaddress1" mock_device.temperature_unit = "C" - mock_device.outdoor_temperature = 5 - mock_device.outdoor_humidity = 25 + mock_device.outdoor_temperature = OUTDOORTEMP + mock_device.outdoor_humidity = OUTDOORHUMIDITY return mock_device @@ -75,7 +141,7 @@ def another_device(): } mock_device.system_mode = "off" mock_device.name = "device2" - mock_device.current_temperature = 20 + mock_device.current_temperature = CURRENTTEMPERATURE mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr new file mode 100644 index 00000000000..4f7d8fe1308 --- /dev/null +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_static_attributes + ReadOnlyDict({ + 'aux_heat': 'off', + 'current_humidity': 50, + 'current_temperature': -6.7, + 'fan_action': 'idle', + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + 'diffuse', + ]), + 'friendly_name': 'device1', + 'humidity': None, + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 99, + 'max_temp': 1.7, + 'min_humidity': 30, + 'min_temp': -13.9, + 'permanent_hold': False, + 'preset_mode': None, + 'preset_modes': list([ + 'none', + 'away', + 'Hold', + ]), + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }) +# --- diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py new file mode 100644 index 00000000000..01472144c59 --- /dev/null +++ b/tests/components/honeywell/test_climate.py @@ -0,0 +1,1071 @@ +"""Test the Whirlpool Sixth Sense climate domain.""" +import datetime +from unittest.mock import MagicMock + +from aiohttp import ClientConnectionError +import aiosomecomfort +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, + PRESET_AWAY, + PRESET_NONE, + SERVICE_SET_AUX_HEAT, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.honeywell.climate import SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from . import init_integration, reset_mock + +from tests.common import async_fire_time_changed + +FAN_ACTION = "fan_action" +PRESET_HOLD = "Hold" + + +async def test_no_thermostats( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test the setup of the climate entities when there are no appliances available.""" + device._data = {} + await init_integration(hass, config_entry) + assert len(hass.states.async_all()) == 0 + + +async def test_static_attributes( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test static climate attributes.""" + await init_integration(hass, config_entry) + + entity_id = f"climate.{device.name}" + entry = er.async_get(hass).async_get(entity_id) + assert entry + + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + attributes = state.attributes + + assert attributes == snapshot(exclude=props("dr_phase")) + + +async def test_dynamic_attributes( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test dynamic attributes.""" + + await init_integration(hass, config_entry) + + entity_id = f"climate.{device.name}" + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + attributes = state.attributes + assert attributes["current_temperature"] == -6.7 + assert attributes["current_humidity"] == 50 + + device.system_mode = "cool" + device.current_temperature = 21 + device.current_humidity = 55 + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == HVACMode.COOL + attributes = state.attributes + assert attributes["current_temperature"] == -6.1 + assert attributes["current_humidity"] == 55 + + device.system_mode = "heat" + device.current_temperature = 61 + device.current_humidity = 50 + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT + attributes = state.attributes + assert attributes["current_temperature"] == 16.1 + assert attributes["current_humidity"] == 50 + + device.system_mode = "auto" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL + attributes = state.attributes + assert attributes["current_temperature"] == 16.1 + assert attributes["current_humidity"] == 50 + + +async def test_mode_service_calls( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test controlling the entity mode through service calls.""" + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("off") + + device.set_system_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("auto") + + device.set_system_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("cool") + + device.set_system_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("heat") + + device.set_system_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("auto") + + device.set_system_mode.reset_mock() + + +async def test_auxheat_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test controlling the auxheat through service calls.""" + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("emheat") + + device.set_system_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("heat") + + +async def test_fan_modes_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test controlling the fan modes through service calls.""" + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + + device.set_fan_mode.assert_called_once_with("auto") + + device.set_fan_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_ON}, + blocking=True, + ) + + device.set_fan_mode.assert_called_once_with("on") + + device.set_fan_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + + device.set_fan_mode.assert_called_once_with("circulate") + + +async def test_service_calls_off_mode( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test controlling the entity through service calls.""" + + device.system_mode = "off" + + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + + device.set_setpoint_heat.reset_mock() + device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + assert "Invalid temperature" in caplog.messages[-1] + + reset_mock(device) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_setpoint_heat.assert_not_called() + device.set_setpoint_cool.assert_not_called() + + reset_mock(device) + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_not_called() + device.set_hold_heat.assert_not_called() + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_cool.assert_not_called() + device.set_setpoint_cool.assert_not_called() + device.set_hold_heat.assert_not_called() + device.set_setpoint_heat.assert_not_called() + + device.set_hold_heat.reset_mock() + device.set_hold_cool.reset_mock() + + device.set_setpoint_cool.reset_mock() + device.set_setpoint_heat.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(False) + device.set_hold_cool.assert_called_once_with(False) + + reset_mock(device) + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_heat.assert_not_called() + device.set_hold_cool.assert_not_called() + + reset_mock(device) + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_heat.assert_not_called() + device.set_hold_cool.assert_not_called() + + +async def test_service_calls_cool_mode( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test controlling the entity through service calls.""" + + device.system_mode = "cool" + + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_hold_cool.assert_called_once_with(datetime.time(2, 30), 59) + device.set_hold_cool.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + + device.set_setpoint_cool.reset_mock() + device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + assert "Invalid temperature" in caplog.messages[-1] + + reset_mock(device) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True, 12) + device.set_hold_heat.assert_not_called() + device.set_setpoint_heat.assert_not_called() + + reset_mock(device) + + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True, 12) + device.set_hold_heat.assert_not_called() + device.set_setpoint_heat.assert_not_called() + assert "Temperature out of range" in caplog.messages[-1] + + reset_mock(device) + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + + device.hold_heat = True + device.hold_cool = True + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) + + device.set_setpoint_cool.assert_called_once() + + reset_mock(device) + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + assert "Couldn't set permanent hold" in caplog.messages[-1] + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_not_called() + device.set_hold_cool.assert_called_once_with(False) + + reset_mock(device) + + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_not_called() + device.set_hold_cool.assert_called_once_with(False) + assert "Can not stop hold mode" in caplog.messages[-1] + + reset_mock(device) + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + + reset_mock(device) + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + assert "Couldn't set permanent hold" in caplog.messages[-1] + + reset_mock(device) + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + device.system_mode = "Junk" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_not_called() + device.set_hold_heat.assert_not_called() + assert "Invalid system mode returned" in caplog.messages[-2] + + +async def test_service_calls_heat_mode( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test controlling the entity through service calls.""" + + device.system_mode = "heat" + + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) + device.set_hold_heat.reset_mock() + + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) + device.set_hold_heat.reset_mock() + assert "Invalid temperature" in caplog.messages[-1] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + + device.set_setpoint_heat.reset_mock() + device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + assert "Invalid temperature" in caplog.messages[-1] + + reset_mock(device) + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(True) + device.set_hold_cool.assert_not_called() + + device.hold_heat = True + device.hold_cool = True + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) + + device.set_setpoint_heat.assert_called_once() + + reset_mock(device) + + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + device.set_hold_heat.assert_called_once_with(True) + device.set_hold_cool.assert_not_called() + assert "Couldn't set permanent hold" in caplog.messages[-1] + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(True, 22) + device.set_hold_cool.assert_not_called() + device.set_setpoint_cool.assert_not_called() + + reset_mock(device) + + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(True, 22) + device.set_hold_cool.assert_not_called() + device.set_setpoint_cool.assert_not_called() + assert "Temperature out of range" in caplog.messages[-1] + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(False) + device.set_hold_cool.assert_called_once_with(False) + + device.set_hold_heat.reset_mock() + device.set_hold_cool.reset_mock() + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(False) + assert "Can not stop hold mode" in caplog.messages[-1] + + reset_mock(device) + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(True) + device.set_hold_cool.assert_not_called() + + reset_mock(device) + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(True) + device.set_hold_cool.assert_not_called() + + reset_mock(device) + + +async def test_service_calls_auto_mode( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test controlling the entity through service calls.""" + + device.system_mode = "auto" + + await init_integration(hass, config_entry) + entity_id = f"climate.{device.name}" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_setpoint_cool.assert_not_called() + device.set_setpoint_heat.assert_not_called() + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_once_with(95) + device.set_setpoint_heat.assert_called_once_with(77) + + reset_mock(device) + + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_setpoint_heat.assert_not_called() + assert "Invalid temperature" in caplog.messages[-1] + + reset_mock(device) + + device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError + device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_heat.assert_not_called() + assert "Invalid temperature" in caplog.messages[-1] + + reset_mock(device) + + device.set_hold_heat.side_effect = None + device.set_hold_cool.side_effect = None + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_called_once_with(True) + + reset_mock(device) + + device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_called_once_with(True) + assert "Couldn't set permanent hold" in caplog.messages[-1] + + reset_mock(device) + device.set_setpoint_heat.side_effect = None + device.set_setpoint_cool.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True, 12) + device.set_hold_heat.assert_called_once_with(True, 22) + + reset_mock(device) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_called_once_with(False) + device.set_hold_cool.assert_called_once_with(False) + + reset_mock(device) + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + device.set_hold_heat.assert_not_called() + device.set_hold_cool.assert_called_once_with(False) + assert "Can not stop hold mode" in caplog.messages[-1] + + reset_mock(device) + + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + + reset_mock(device) + + device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError + device.raw_ui_data["StatusHeat"] = 2 + device.raw_ui_data["StatusCool"] = 2 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) + + device.set_hold_cool.assert_called_once_with(True) + device.set_hold_heat.assert_not_called() + assert "Couldn't set permanent hold" in caplog.messages[-1] + + +async def test_async_update_errors( + hass: HomeAssistant, + device: MagicMock, + config_entry: MagicMock, + client: MagicMock, +) -> None: + """Test update with errors.""" + + await init_integration(hass, config_entry) + + device.refresh.side_effect = aiosomecomfort.SomeComfortError + client.login.side_effect = aiosomecomfort.SomeComfortError + entity_id = f"climate.{device.name}" + state = hass.states.get(entity_id) + assert state.state == "off" + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "unavailable" + + reset_mock(device) + device.refresh.side_effect = None + client.login.side_effect = None + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "off" + + # "reload integration" test + device.refresh.side_effect = aiosomecomfort.SomeComfortError + client.login.side_effect = aiosomecomfort.AuthError + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + entity_id = f"climate.{device.name}" + state = hass.states.get(entity_id) + assert state.state == "unavailable" + + device.refresh.side_effect = ClientConnectionError + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + entity_id = f"climate.{device.name}" + state = hass.states.get(entity_id) + assert state.state == "unavailable" + + +async def test_aux_heat_off_service_call( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test aux heat off turns of system when no heat configured.""" + device.raw_ui_data["SwitchHeatAllowed"] = False + device.raw_ui_data["SwitchAutoAllowed"] = False + device.raw_ui_data["SwitchEmergencyHeatAllowed"] = True + + await init_integration(hass, config_entry) + + entity_id = f"climate.{device.name}" + entry = er.async_get(hass).async_get(entity_id) + assert entry + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("off") diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 29c253d6a5e..36c94c83f31 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,5 +1,5 @@ """Test honeywell setup process.""" -from unittest.mock import create_autospec, patch +from unittest.mock import MagicMock, create_autospec, patch import aiosomecomfort import pytest @@ -13,6 +13,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from . import init_integration + from tests.common import MockConfigEntry MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE} @@ -28,7 +30,6 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - assert hass.states.async_entity_ids_count() == 1 -@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device ) -> None: @@ -41,7 +42,6 @@ async def test_setup_multiple_thermostats( assert hass.states.async_entity_ids_count() == 2 -@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_multiple_thermostats_with_same_deviceid( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -82,3 +82,30 @@ async def test_away_temps_migration(hass: HomeAssistant) -> None: CONF_COOL_AWAY_TEMPERATURE: 1, CONF_HEAT_AWAY_TEMPERATURE: 2, } + + +async def test_login_error( + hass: HomeAssistant, client: MagicMock, config_entry: MagicMock +) -> None: + """Test login errors from API.""" + client.login.side_effect = aiosomecomfort.AuthError + await init_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_error( + hass: HomeAssistant, client: MagicMock, config_entry: MagicMock +) -> None: + """Test Connection errors from API.""" + client.login.side_effect = aiosomecomfort.ConnectionError + await init_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_no_devices( + hass: HomeAssistant, client: MagicMock, config_entry: MagicMock +) -> None: + """Test no devices from API.""" + client.locations_by_id = {} + await init_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 0c346ab947c..8f9fff79580 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -4,11 +4,10 @@ from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging -import pathlib +from pathlib import Path import time from unittest.mock import MagicMock, Mock, patch -import py import pytest from homeassistant.auth.providers.legacy_api_password import ( @@ -26,22 +25,24 @@ from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator -def _setup_broken_ssl_pem_files(tmpdir): - test_dir = tmpdir.mkdir("test_broken_ssl") - cert_path = pathlib.Path(test_dir) / "cert.pem" +def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]: + test_dir = tmp_path / "test_broken_ssl" + test_dir.mkdir() + cert_path = test_dir / "cert.pem" cert_path.write_text("garbage") - key_path = pathlib.Path(test_dir) / "key.pem" + key_path = test_dir / "key.pem" key_path.write_text("garbage") return cert_path, key_path -def _setup_empty_ssl_pem_files(tmpdir): - test_dir = tmpdir.mkdir("test_empty_ssl") - cert_path = pathlib.Path(test_dir) / "cert.pem" +def _setup_empty_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path, Path]: + test_dir = tmp_path / "test_empty_ssl" + test_dir.mkdir() + cert_path = test_dir / "cert.pem" cert_path.write_text("-") - peer_cert_path = pathlib.Path(test_dir) / "peer_cert.pem" + peer_cert_path = test_dir / "peer_cert.pem" peer_cert_path.write_text("-") - key_path = pathlib.Path(test_dir) / "key.pem" + key_path = test_dir / "key.pem" key_path.write_text("-") return cert_path, key_path, peer_cert_path @@ -154,13 +155,11 @@ async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None: ) -async def test_ssl_profile_defaults_modern( - hass: HomeAssistant, tmpdir: py.path.local -) -> None: +async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmp_path: Path) -> None: """Test default ssl profile.""" cert_path, key_path, _ = await hass.async_add_executor_job( - _setup_empty_ssl_pem_files, tmpdir + _setup_empty_ssl_pem_files, tmp_path ) with patch("ssl.SSLContext.load_cert_chain"), patch( @@ -182,12 +181,12 @@ async def test_ssl_profile_defaults_modern( async def test_ssl_profile_change_intermediate( - hass: HomeAssistant, tmpdir: py.path.local + hass: HomeAssistant, tmp_path: Path ) -> None: """Test setting ssl profile to intermediate.""" cert_path, key_path, _ = await hass.async_add_executor_job( - _setup_empty_ssl_pem_files, tmpdir + _setup_empty_ssl_pem_files, tmp_path ) with patch("ssl.SSLContext.load_cert_chain"), patch( @@ -214,13 +213,11 @@ async def test_ssl_profile_change_intermediate( assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_modern( - hass: HomeAssistant, tmpdir: py.path.local -) -> None: +async def test_ssl_profile_change_modern(hass: HomeAssistant, tmp_path: Path) -> None: """Test setting ssl profile to modern.""" cert_path, key_path, _ = await hass.async_add_executor_job( - _setup_empty_ssl_pem_files, tmpdir + _setup_empty_ssl_pem_files, tmp_path ) with patch("ssl.SSLContext.load_cert_chain"), patch( @@ -247,10 +244,10 @@ async def test_ssl_profile_change_modern( assert len(mock_context.mock_calls) == 1 -async def test_peer_cert(hass: HomeAssistant, tmpdir: py.path.local) -> None: +async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None: """Test required peer cert.""" cert_path, key_path, peer_cert_path = await hass.async_add_executor_job( - _setup_empty_ssl_pem_files, tmpdir + _setup_empty_ssl_pem_files, tmp_path ) with patch("ssl.SSLContext.load_cert_chain"), patch( @@ -282,12 +279,12 @@ async def test_peer_cert(hass: HomeAssistant, tmpdir: py.path.local) -> None: async def test_emergency_ssl_certificate_when_invalid( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test http can startup with an emergency self signed cert when the current one is broken.""" cert_path, key_path = await hass.async_add_executor_job( - _setup_broken_ssl_pem_files, tmpdir + _setup_broken_ssl_pem_files, tmp_path ) hass.config.safe_mode = True @@ -313,12 +310,12 @@ async def test_emergency_ssl_certificate_when_invalid( async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test an emergency cert is only used in safe mode.""" cert_path, key_path = await hass.async_add_executor_job( - _setup_broken_ssl_pem_files, tmpdir + _setup_broken_ssl_pem_files, tmp_path ) assert ( @@ -330,14 +327,14 @@ async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( async def test_emergency_ssl_certificate_when_invalid_get_url_fails( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. Ensure we can still start of we cannot determine the external url as well. """ cert_path, key_path = await hass.async_add_executor_job( - _setup_broken_ssl_pem_files, tmpdir + _setup_broken_ssl_pem_files, tmp_path ) hass.config.safe_mode = True @@ -367,12 +364,12 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( async def test_invalid_ssl_and_cannot_create_emergency_cert( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.""" cert_path, key_path = await hass.async_add_executor_job( - _setup_broken_ssl_pem_files, tmpdir + _setup_broken_ssl_pem_files, tmp_path ) hass.config.safe_mode = True @@ -398,7 +395,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. @@ -409,7 +406,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( """ cert_path, key_path = await hass.async_add_executor_job( - _setup_broken_ssl_pem_files, tmpdir + _setup_broken_ssl_pem_files, tmp_path ) hass.config.safe_mode = True diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index 0b4947847fa..4ac765d7f50 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -31,7 +31,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 4b2f243aba6..a59761d1c74 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -16,6 +16,12 @@ from tests.common import assert_setup_component, async_capture_events from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def aiohttp_unused_port(event_loop, aiohttp_unused_port, socket_enabled): """Return aiohttp_unused_port and allow opening sockets.""" @@ -58,6 +64,7 @@ async def test_setup_component(hass: HomeAssistant) -> None: with assert_setup_component(1, ip.DOMAIN): assert await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() async def test_setup_component_with_service(hass: HomeAssistant) -> None: @@ -66,6 +73,7 @@ async def test_setup_component_with_service(hass: HomeAssistant) -> None: with assert_setup_component(1, ip.DOMAIN): assert await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() assert hass.services.has_service(ip.DOMAIN, "scan") diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 20c9ddf8938..82430549f05 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -1,5 +1,6 @@ """Test the imap config flow.""" import asyncio +import ssl from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException @@ -113,10 +114,16 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "exc", - [asyncio.TimeoutError, AioImapException("")], + ("exc", "error"), + [ + (asyncio.TimeoutError, "cannot_connect"), + (AioImapException(""), "cannot_connect"), + (ssl.SSLError, "ssl_error"), + ], ) -async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, exc: Exception, error: str +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -131,7 +138,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: ) assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": error} # make sure we do not lose the user input if somethings gets wrong assert { @@ -388,3 +395,102 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "already_configured"} + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "name": "IMAP", + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "folder": "INBOX", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "IMAP" + assert result2["data"] == { + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "charset": "utf-8", + "folder": "INBOX", + "search": "UnSeen UnDeleted", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_connection_error(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server", + side_effect=AioImapException("Unexpected error"), + ), patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "name": "IMAP", + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "folder": "INBOX", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@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 +) -> None: + """Test with alternate cipherlist.""" + config = MOCK_CONFIG.copy() + config["ssl_cipher_list"] = cipher_list + 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" + ) as mock_client: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], config + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "email@email.com" + assert result2["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 60efde71435..58bb084296a 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -30,12 +30,19 @@ from .test_config_flow import MOCK_CONFIG from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed +@pytest.mark.parametrize( + "cipher_list", [None, "python_default", "modern", "intermediate"] +) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_entry_startup_and_unload( - hass: HomeAssistant, mock_imap_protocol: MagicMock + hass: HomeAssistant, mock_imap_protocol: MagicMock, cipher_list: str ) -> None: - """Test imap entry startup and unload with push and polling coordinator.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + """Test imap entry startup and unload with push and polling coordinator and alternate ciphers.""" + config = MOCK_CONFIG.copy() + if cipher_list: + config["ssl_cipher_list"] = cipher_list + + config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/imap_email_content/test_repairs.py b/tests/components/imap_email_content/test_repairs.py new file mode 100644 index 00000000000..6323dcde377 --- /dev/null +++ b/tests/components/imap_email_content/test_repairs.py @@ -0,0 +1,296 @@ +"""Test repairs for imap_email_content.""" + +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def mock_client() -> Generator[MagicMock, None, None]: + """Mock the imap client.""" + with patch( + "homeassistant.components.imap_email_content.sensor.EmailReader.read_next", + return_value=None, + ), patch("imaplib.IMAP4_SSL") as mock_imap_client: + yield mock_imap_client + + +CONFIG = { + "platform": "imap_email_content", + "name": "Notifications", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": "{{ body }}", + "senders": ["company@example.com"], +} +DESCRIPTION_PLACEHOLDERS = { + "yaml_example": "" + "template:\n" + "- sensor:\n" + " - name: Notifications\n" + " state: '{{ trigger.event.data[\"text\"] }}'\n" + " trigger:\n - event_data:\n" + " sender: company@example.com\n" + " event_type: imap_content\n" + " id: custom_event\n" + " platform: event\n", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": '{{ trigger.event.data["text"] }}', + "name": "Notifications", +} + +CONFIG_DEFAULT = { + "platform": "imap_email_content", + "name": "Notifications", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "senders": ["company@example.com"], +} +DESCRIPTION_PLACEHOLDERS_DEFAULT = { + "yaml_example": "" + "template:\n" + "- sensor:\n" + " - name: Notifications\n" + " state: '{{ trigger.event.data[\"subject\"] }}'\n" + " trigger:\n - event_data:\n" + " sender: company@example.com\n" + " event_type: imap_content\n" + " id: custom_event\n" + " platform: event\n", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": '{{ trigger.event.data["subject"] }}', + "name": "Notifications", +} + + +@pytest.mark.parametrize( + ("config", "description_placeholders"), + [ + (CONFIG, DESCRIPTION_PLACEHOLDERS), + (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), + ], + ids=["with_value_template", "default_subject"], +) +async def test_deprecation_repair_flow( + hass: HomeAssistant, + mock_client: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + config: str | None, + description_placeholders: str, +) -> None: + """Test the deprecation repair flow.""" + # setup config + await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.notifications") + assert state is not None + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert issue["is_fixable"] + url = RepairsFlowIndexView.url + resp = await client.post( + url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "start" + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + resp = await client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +@pytest.mark.parametrize( + ("config", "description_placeholders"), + [ + (CONFIG, DESCRIPTION_PLACEHOLDERS), + (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), + ], + ids=["with_value_template", "default_subject"], +) +async def test_repair_flow_where_entry_already_exists( + hass: HomeAssistant, + mock_client: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + config: str | None, + description_placeholders: str, +) -> None: + """Test the deprecation repair flow and an entry already exists.""" + + await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + state = hass.states.get("sensor.notifications") + assert state is not None + + existing_imap_entry_config = { + "username": "john.doe@example.com", + "password": "password", + "server": "imap.example.com", + "port": 993, + "charset": "utf-8", + "folder": "INBOX.Notifications", + "search": "UnSeen UnDeleted", + } + + with patch("homeassistant.components.imap.async_setup_entry", return_value=True): + imap_entry = MockConfigEntry(domain="imap", data=existing_imap_entry_config) + imap_entry.add_to_hass(hass) + await hass.config_entries.async_setup(imap_entry.entry_id) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert issue["is_fixable"] + assert issue["translation_key"] == "migration" + + url = RepairsFlowIndexView.url + resp = await client.post( + url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + resp = await client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "already_configured" + + # We should now have a non_fixable issue left since there is still + # a config in configuration.yaml + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert not issue["is_fixable"] + assert issue["translation_key"] == "deprecation" diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py index ba2b362af73..3e8a6c1e282 100644 --- a/tests/components/imap_email_content/test_sensor.py +++ b/tests/components/imap_email_content/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.imap_email_content import sensor as imap_email_con from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.template import Template +from homeassistant.setup import async_setup_component class FakeEMailReader: @@ -37,6 +38,11 @@ class FakeEMailReader: return self._messages.popleft() +async def test_integration_setup_(hass: HomeAssistant) -> None: + """Test the integration component setup is successful.""" + assert await async_setup_component(hass, "imap_email_content", {}) + + async def test_allowed_sender(hass: HomeAssistant) -> None: """Test emails from allowed sender.""" test_message = email.message.Message() diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index c2e759ec72a..a59ae7b85c3 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -30,7 +30,9 @@ async def test_exclude_attributes( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index 0887756ae18..dd5f7530493 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -31,7 +31,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index 59abefdd7d8..fe96b7cfb2d 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -35,7 +35,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 1e489ec40c3..4172d169deb 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -43,7 +43,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 084009b163a..f4ac98dfc39 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -42,7 +42,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index bc2e7d9f5af..001f56a5a3e 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -42,7 +42,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index dd867b58166..93da55c51a4 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -401,13 +401,20 @@ async def test_units(hass: HomeAssistant) -> None: # When source state goes to None / Unknown, expect an early exit without # changes to the state or unit_of_measurement - hass.states.async_set(entity_id, STATE_UNAVAILABLE, None) + hass.states.async_set(entity_id, None, None) await hass.async_block_till_done() new_state = hass.states.get("sensor.integration") assert state == new_state assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.WATT_HOUR + # When source state goes to unavailable, expect sensor to also become unavailable + hass.states.async_set(entity_id, STATE_UNAVAILABLE, None) + await hass.async_block_till_done() + + new_state = hass.states.get("sensor.integration") + assert new_state.state == STATE_UNAVAILABLE + async def test_device_class(hass: HomeAssistant) -> None: """Test integration sensor units using a power source.""" diff --git a/tests/components/iss/test_config_flow.py b/tests/components/iss/test_config_flow.py index f206f5d8580..fec4d9b192c 100644 --- a/tests/components/iss/test_config_flow.py +++ b/tests/components/iss/test_config_flow.py @@ -18,7 +18,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: ) assert result.get("type") == data_entry_flow.FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" with patch("homeassistant.components.iss.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 60c99b5083e..fe344332f38 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -8,21 +8,12 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp, ssdp from homeassistant.components.isy994.const import ( - CONF_IGNORE_STRING, - CONF_RESTORE_LIGHT_STATE, - CONF_SENSOR_STRING, CONF_TLS_VER, - CONF_VAR_SENSOR_STRING, DOMAIN, ISY_URL_POSTFIX, UDN_UUID_PREFIX, ) -from homeassistant.config_entries import ( - SOURCE_DHCP, - SOURCE_IGNORE, - SOURCE_IMPORT, - SOURCE_SSDP, -) +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -51,27 +42,6 @@ MOCK_IOX_USER_INPUT = { CONF_PASSWORD: MOCK_PASSWORD, CONF_TLS_VER: MOCK_TLS_VERSION, } -MOCK_IMPORT_WITH_SSL = { - CONF_HOST: f"https://{MOCK_HOSTNAME}", - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, - CONF_TLS_VER: MOCK_TLS_VERSION, -} -MOCK_IMPORT_BASIC_CONFIG = { - CONF_HOST: f"http://{MOCK_HOSTNAME}", - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, -} -MOCK_IMPORT_FULL_CONFIG = { - CONF_HOST: f"http://{MOCK_HOSTNAME}", - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, - CONF_IGNORE_STRING: MOCK_IGNORE_STRING, - CONF_RESTORE_LIGHT_STATE: MOCK_RESTORE_LIGHT_STATE, - CONF_SENSOR_STRING: MOCK_SENSOR_STRING, - CONF_TLS_VER: MOCK_TLS_VERSION, - CONF_VAR_SENSOR_STRING: MOCK_VARIABLE_SENSOR_STRING, -} MOCK_DEVICE_NAME = "Name of the device" MOCK_UUID = "ce:fb:72:31:b7:b9" @@ -121,8 +91,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -135,7 +103,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})" assert result2["result"].unique_id == MOCK_UUID assert result2["data"] == MOCK_USER_INPUT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -271,72 +238,6 @@ async def test_form_existing_config_entry(hass: HomeAssistant) -> None: assert result2["type"] == data_entry_flow.FlowResultType.ABORT -async def test_import_flow_some_fields(hass: HomeAssistant) -> None: - """Test import config flow with just the basic fields.""" - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ), patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_IMPORT_BASIC_CONFIG, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}" - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - - -async def test_import_flow_with_https(hass: HomeAssistant) -> None: - """Test import config with https.""" - - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ), patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_IMPORT_WITH_SSL, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == f"https://{MOCK_HOSTNAME}" - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - - -async def test_import_flow_all_fields(hass: HomeAssistant) -> None: - """Test import config flow with all fields.""" - with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ), patch( - PATCH_ASYNC_SETUP_ENTRY, - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_IMPORT_FULL_CONFIG, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == f"http://{MOCK_HOSTNAME}" - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_IGNORE_STRING] == MOCK_IGNORE_STRING - assert result["data"][CONF_RESTORE_LIGHT_STATE] == MOCK_RESTORE_LIGHT_STATE - assert result["data"][CONF_SENSOR_STRING] == MOCK_SENSOR_STRING - assert result["data"][CONF_VAR_SENSOR_STRING] == MOCK_VARIABLE_SENSOR_STRING - assert result["data"][CONF_TLS_VER] == MOCK_TLS_VERSION - - async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: """Test ssdp abort when the serial number is already configured.""" @@ -383,8 +284,6 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -398,7 +297,6 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})" assert result2["result"].unique_id == MOCK_UUID assert result2["data"] == MOCK_USER_INPUT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -545,8 +443,6 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -560,7 +456,6 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})" assert result2["result"].unique_id == MOCK_UUID assert result2["data"] == MOCK_USER_INPUT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -585,8 +480,6 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: ) with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -600,7 +493,6 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})" assert result2["result"].unique_id == MOCK_UUID assert result2["data"] == MOCK_IOX_USER_INPUT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -625,8 +517,6 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: ) with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -640,7 +530,6 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})" assert result2["result"].unique_id == MOCK_UUID assert result2["data"] == MOCK_IOX_USER_INPUT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 4fa8d02716f..eb3fee7eaf5 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -1,9 +1,13 @@ """Test KNX button.""" from datetime import timedelta +import logging + +import pytest from homeassistant.components.knx.const import ( CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, + DOMAIN, KNX_ADDRESS, ) from homeassistant.components.knx.schema import ButtonSchema @@ -86,3 +90,49 @@ async def test_button_type(hass: HomeAssistant, knx: KNXTestKit) -> None: "button", "press", {"entity_id": "button.test"}, blocking=True ) await knx.assert_write("1/2/3", (0x0C, 0x33)) + + +@pytest.mark.parametrize( + ("conf_type", "conf_value", "error_msg"), + [ + ( + "2byte_float", + "not_valid", + "'payload: not_valid' not valid for 'type: 2byte_float'", + ), + ( + "not_valid", + 3, + "type 'not_valid' is not a valid DPT identifier", + ), + ], +) +async def test_button_invalid( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + knx: KNXTestKit, + conf_type: str, + conf_value: str, + error_msg: str, +) -> None: + """Test KNX button with configured payload that can't be encoded.""" + with caplog.at_level(logging.ERROR): + await knx.setup_integration( + { + ButtonSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + ButtonSchema.CONF_VALUE: conf_value, + CONF_TYPE: conf_type, + } + } + ) + assert len(caplog.messages) == 2 + record = caplog.records[0] + assert record.levelname == "ERROR" + assert f"Invalid config for [knx]: {error_msg}" in record.message + record = caplog.records[1] + assert record.levelname == "ERROR" + assert "Setup failed for knx: Invalid config." in record.message + assert hass.states.get("button.test") is None + assert hass.data.get(DOMAIN) is None diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index a20c6663f08..f5c1aed7fde 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -1,4 +1,7 @@ """Test KNX events.""" +import logging + +import pytest from homeassistant.components.knx import CONF_EVENT, CONF_TYPE, KNX_ADDRESS from homeassistant.core import HomeAssistant @@ -8,7 +11,11 @@ from .conftest import KNXTestKit from tests.common import async_capture_events -async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_knx_event( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + knx: KNXTestKit, +) -> None: """Test the `knx_event` event.""" test_group_a = "0/4/*" test_address_a_1 = "0/4/0" @@ -95,3 +102,16 @@ async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.receive_write("2/6/6", True) await hass.async_block_till_done() assert len(events) == 0 + + # receive telegrams with wrong payload length + caplog.clear() + with caplog.at_level(logging.WARNING): + await knx.receive_write(test_address_a_1, (0x03, 0x2F, 0xFF)) + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert ( + "Error in `knx_event` at decoding type " + "'DPT2ByteUnsigned' from telegram" in record.message + ) + await test_event_data(test_address_a_1, (0x03, 0x2F, 0xFF), value=None) diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index e45729559c1..6eab80d52cb 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -23,20 +23,20 @@ async def test_diagnostic_entities( for entity_id in [ "sensor.knx_interface_individual_address", - "sensor.knx_interface_connected_since", + "sensor.knx_interface_connection_established", "sensor.knx_interface_connection_type", - "sensor.knx_interface_telegrams_incoming", - "sensor.knx_interface_telegrams_incoming_error", - "sensor.knx_interface_telegrams_outgoing", - "sensor.knx_interface_telegrams_outgoing_error", + "sensor.knx_interface_incoming_telegrams", + "sensor.knx_interface_incoming_telegram_errors", + "sensor.knx_interface_outgoing_telegrams", + "sensor.knx_interface_outgoing_telegram_errors", "sensor.knx_interface_telegrams", ]: entity = entity_registry.async_get(entity_id) assert entity.entity_category is EntityCategory.DIAGNOSTIC for entity_id in [ - "sensor.knx_interface_telegrams_incoming", - "sensor.knx_interface_telegrams_outgoing", + "sensor.knx_interface_incoming_telegrams", + "sensor.knx_interface_outgoing_telegrams", ]: entity = entity_registry.async_get(entity_id) assert entity.disabled is True @@ -57,8 +57,8 @@ async def test_diagnostic_entities( ("sensor.knx_interface_individual_address", "0.0.0"), ("sensor.knx_interface_connection_type", "Tunnel TCP"), # skipping connected_since timestamp - ("sensor.knx_interface_telegrams_incoming_error", "1"), - ("sensor.knx_interface_telegrams_outgoing_error", "2"), + ("sensor.knx_interface_incoming_telegram_errors", "1"), + ("sensor.knx_interface_outgoing_telegram_errors", "2"), ("sensor.knx_interface_telegrams", "31"), ]: assert hass.states.get(entity_id).state == test_state @@ -88,8 +88,8 @@ async def test_diagnostic_entities( ("sensor.knx_interface_individual_address", "1.1.1"), ("sensor.knx_interface_connection_type", "Tunnel UDP"), # skipping connected_since timestamp - ("sensor.knx_interface_telegrams_incoming_error", "0"), - ("sensor.knx_interface_telegrams_outgoing_error", "0"), + ("sensor.knx_interface_incoming_telegram_errors", "0"), + ("sensor.knx_interface_outgoing_telegram_errors", "0"), ("sensor.knx_interface_telegrams", "0"), ]: assert hass.states.get(entity_id).state == test_state @@ -105,7 +105,7 @@ async def test_removed_entity( knx.xknx.connection_manager, "unregister_connection_state_changed_cb" ) as unregister_mock: entity_registry.async_update_entity( - "sensor.knx_interface_connected_since", + "sensor.knx_interface_connection_established", disabled_by=er.RegistryEntryDisabler.USER, ) await hass.async_block_till_done() diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index 9c62ca3f94b..5d8b703cdcd 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_create_sensors +# name: test_create_sensors[mock_heat_meter_response0] list([ StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -53,7 +53,7 @@ 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '450.0', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -64,7 +64,7 @@ 'entity_id': 'sensor.heat_meter_ownership_number', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '123a', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -75,7 +75,7 @@ 'entity_id': 'sensor.heat_meter_error_number', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -86,7 +86,7 @@ 'entity_id': 'sensor.heat_meter_device_number', 'last_changed': , 'last_updated': , - 'state': 'devicenr_789', + 'state': 'abc1', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -98,7 +98,7 @@ 'entity_id': 'sensor.heat_meter_measurement_period_minutes', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '60', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -110,7 +110,7 @@ 'entity_id': 'sensor.heat_meter_power_max', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '22.1', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -122,7 +122,7 @@ 'entity_id': 'sensor.heat_meter_power_max_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '22.4', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -134,7 +134,7 @@ 'entity_id': 'sensor.heat_meter_flowrate_max', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '0.744', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -146,7 +146,7 @@ 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '0.743', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -158,7 +158,7 @@ 'entity_id': 'sensor.heat_meter_return_temperature_max', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '96.1', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -170,7 +170,7 @@ 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '96.2', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -182,7 +182,7 @@ 'entity_id': 'sensor.heat_meter_flow_temperature_max', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '98.5', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -194,7 +194,7 @@ 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '98.4', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -206,7 +206,7 @@ 'entity_id': 'sensor.heat_meter_operating_hours', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '115575', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -218,7 +218,7 @@ 'entity_id': 'sensor.heat_meter_flow_hours', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '30242', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -230,7 +230,7 @@ 'entity_id': 'sensor.heat_meter_fault_hours', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '5', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -242,7 +242,7 @@ 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '5', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -253,7 +253,7 @@ 'entity_id': 'sensor.heat_meter_yearly_set_day', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '01-01', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -264,7 +264,7 @@ 'entity_id': 'sensor.heat_meter_monthly_set_day', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '01', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -276,7 +276,7 @@ 'entity_id': 'sensor.heat_meter_meter_date_time', 'last_changed': , 'last_updated': , - 'state': '2022-05-20T02:41:17+00:00', + 'state': '2022-05-19T19:41:17+00:00', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -288,7 +288,7 @@ 'entity_id': 'sensor.heat_meter_measuring_range', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '1.5', }), StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -298,7 +298,310 @@ 'entity_id': 'sensor.heat_meter_settings_and_firmware', 'last_changed': , 'last_updated': , - 'state': 'unknown', + 'state': '0 1 0 0000 CECV CECV 1 5.16 5.16 F 101008 040404 08 0', + }), + ]) +# --- +# name: test_create_sensors[mock_heat_meter_response1] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage MWh', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_mwh', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat previous year MWh', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_mwh', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '450.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': '123a', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'abc1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': '60', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': '22.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '22.4', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': '0.744', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '0.743', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': '96.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '96.2', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': '98.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '98.4', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': '115575', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': '30242', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': '5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': '5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': '01-01', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': '01', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-19T19:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': '1.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': '0 1 0 0000 CECV CECV 1 5.16 5.16 F 101008 040404 08 0', }), ]) # --- diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 4de58a206e6..e28ebe695b3 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -1,10 +1,11 @@ """The tests for the Landis+Gyr Heat Meter sensor platform.""" -from dataclasses import dataclass import datetime from unittest.mock import patch +import pytest import serial from syrupy import SnapshotAssertion +from ultraheat_api.response import HeatMeterResponse from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL @@ -20,24 +21,85 @@ API_HEAT_METER_SERVICE = ( "homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService" ) +MOCK_RESPONSE_GJ = { + "model": "abc", + "heat_usage_gj": 123.0, + "heat_usage_mwh": None, + "volume_usage_m3": 456.0, + "ownership_number": "123a", + "volume_previous_year_m3": 450.0, + "heat_previous_year_gj": 111.0, + "heat_previous_year_mwh": None, + "error_number": "0", + "device_number": "abc1", + "measurement_period_minutes": 60, + "power_max_kw": 22.1, + "power_max_previous_year_kw": 22.4, + "flowrate_max_m3ph": 0.744, + "flow_temperature_max_c": 98.5, + "flowrate_max_previous_year_m3ph": 0.743, + "return_temperature_max_c": 96.1, + "flow_temperature_max_previous_year_c": 98.4, + "return_temperature_max_previous_year_c": 96.2, + "operating_hours": 115575, + "fault_hours": 5, + "fault_hours_previous_year": 5, + "yearly_set_day": "01-01", + "monthly_set_day": "01", + "meter_date_time": dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), + "measuring_range_m3ph": 1.5, + "settings_and_firmware": "0 1 0 0000 CECV CECV 1 5.16 5.16 F 101008 040404 08 0", + "flow_hours": 30242, + "raw_response": "6.8(0328.872*GJ)6.26(03329.68*m3)9.21(66153690)", +} -@dataclass -class MockHeatMeterResponse: - """Mock for HeatMeterResponse.""" - - heat_usage_gj: float - volume_usage_m3: float - heat_previous_year_gj: float - device_number: str - meter_date_time: datetime.datetime +MOCK_RESPONSE_MWH = { + "model": "abc", + "heat_usage_gj": None, + "heat_usage_mwh": 123.0, + "volume_usage_m3": 456.0, + "ownership_number": "123a", + "volume_previous_year_m3": 450.0, + "heat_previous_year_gj": None, + "heat_previous_year_mwh": 111.0, + "error_number": "0", + "device_number": "abc1", + "measurement_period_minutes": 60, + "power_max_kw": 22.1, + "power_max_previous_year_kw": 22.4, + "flowrate_max_m3ph": 0.744, + "flow_temperature_max_c": 98.5, + "flowrate_max_previous_year_m3ph": 0.743, + "return_temperature_max_c": 96.1, + "flow_temperature_max_previous_year_c": 98.4, + "return_temperature_max_previous_year_c": 96.2, + "operating_hours": 115575, + "fault_hours": 5, + "fault_hours_previous_year": 5, + "yearly_set_day": "01-01", + "monthly_set_day": "01", + "meter_date_time": dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), + "measuring_range_m3ph": 1.5, + "settings_and_firmware": "0 1 0 0000 CECV CECV 1 5.16 5.16 F 101008 040404 08 0", + "flow_hours": 30242, + "raw_response": "6.8(0328.872*MWh)6.26(03329.68*m3)9.21(66153690)", +} +@pytest.mark.parametrize( + "mock_heat_meter_response", + [ + MOCK_RESPONSE_GJ, + MOCK_RESPONSE_MWH, + ], +) @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_heat_meter_response, ) -> None: """Test sensor.""" entry_data = { @@ -48,13 +110,7 @@ async def test_create_sensors( mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) mock_entry.add_to_hass(hass) - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123.0, - volume_usage_m3=456.0, - heat_previous_year_gj=111.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), - ) + mock_heat_meter_response = HeatMeterResponse(**mock_heat_meter_response) mock_heat_meter().read.return_value = mock_heat_meter_response @@ -77,13 +133,7 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non mock_entry.add_to_hass(hass) # First setup normally - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123.0, - volume_usage_m3=456.0, - heat_previous_year_gj=111.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), - ) + mock_heat_meter_response = HeatMeterResponse(**MOCK_RESPONSE_GJ) mock_heat_meter().read.return_value = mock_heat_meter_response @@ -103,14 +153,9 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state.state == STATE_UNAVAILABLE - # Now 'enable' and see if next poll succeeds - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=124.0, - volume_usage_m3=457.0, - heat_previous_year_gj=112.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), - ) + # # Now 'enable' and see if next poll succeeds + mock_heat_meter_response = HeatMeterResponse(**MOCK_RESPONSE_GJ) + mock_heat_meter_response.heat_usage_gj += 1 mock_heat_meter().read.return_value = mock_heat_meter_response mock_heat_meter().read.side_effect = None diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index 6458e617dc0..0fa45a12277 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -24,6 +24,7 @@ class MockUser: 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.""" @@ -48,7 +49,9 @@ class MockUser: @pytest.fixture(name="lastfm_network") def lastfm_network_fixture(): """Create fixture for LastFMNetwork.""" - with patch("pylast.LastFMNetwork") as lastfm_network: + with patch( + "homeassistant.components.lastfm.sensor.LastFMNetwork" + ) as lastfm_network: yield lastfm_network diff --git a/tests/components/launch_library/test_config_flow.py b/tests/components/launch_library/test_config_flow.py index ef88c4d1bb6..8a8a2a94937 100644 --- a/tests/components/launch_library/test_config_flow.py +++ b/tests/components/launch_library/test_config_flow.py @@ -17,7 +17,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: ) assert result.get("type") == data_entry_flow.FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" with patch( "homeassistant.components.launch_library.async_setup_entry", return_value=True diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 0278b8ec3a7..dadb57d09ad 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -254,7 +254,7 @@ def _patch_config_flow_try_connect( ): """Patch out discovery.""" - class MockLifxConnecton: + class MockLifxConnection: """Mock lifx discovery.""" def __init__(self, *args, **kwargs): @@ -275,7 +275,7 @@ def _patch_config_flow_try_connect( def _patcher(): with patch( "homeassistant.components.lifx.config_flow.LIFXConnection", - MockLifxConnecton, + MockLifxConnection, ): yield diff --git a/tests/components/lifx/conftest.py b/tests/components/lifx/conftest.py index a243132dc65..592dd07080f 100644 --- a/tests/components/lifx/conftest.py +++ b/tests/components/lifx/conftest.py @@ -3,6 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.lifx import config_flow, coordinator, util + from tests.common import mock_device_registry, mock_registry @@ -34,6 +36,17 @@ def lifx_mock_get_source_ip(mock_get_source_ip): """Mock network util's async_get_source_ip.""" +@pytest.fixture(autouse=True) +def lifx_no_wait_for_timeouts(): + """Avoid waiting for timeouts in tests.""" + with patch.object(util, "OVERALL_TIMEOUT", 0), patch.object( + config_flow, "OVERALL_TIMEOUT", 0 + ), patch.object(coordinator, "OVERALL_TIMEOUT", 0), patch.object( + coordinator, "MAX_UPDATE_TIME", 0 + ): + yield + + @pytest.fixture(autouse=True) def lifx_mock_async_get_ipv4_broadcast_addresses(): """Mock network util's async_get_ipv4_broadcast_addresses.""" diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index df3209cf066..2adea42bed4 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -546,9 +546,11 @@ async def test_suggested_area(hass: HomeAssistant) -> None: self.bulb = bulb self.lifx_group = kwargs.get("lifx_group") - def __call__(self, *args, **kwargs): + def __call__(self, callb=None, *args, **kwargs): """Call command.""" self.bulb.group = self.lifx_group + if callb: + callb(self.bulb, self.lifx_group) config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 965568d7e6e..42c540a74ef 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -363,7 +363,7 @@ async def test_light_strip(hass: HomeAssistant) -> None: ) # set a one zone assert len(bulb.set_power.calls) == 2 - assert len(bulb.get_color_zones.calls) == 2 + assert len(bulb.get_color_zones.calls) == 1 assert len(bulb.set_color.calls) == 0 call_dict = bulb.set_color_zones.calls[0][1] call_dict.pop("callb") @@ -1124,7 +1124,7 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: entity_id = "light.my_bulb" class MockFailingLifxCommand: - """Mock a lifx command that fails on the 3rd try.""" + """Mock a lifx command that fails on the 2nd try.""" def __init__(self, bulb, **kwargs): """Init command.""" @@ -1134,7 +1134,7 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: def __call__(self, callb=None, *args, **kwargs): """Call command.""" self.call_count += 1 - response = None if self.call_count >= 3 else MockMessage() + response = None if self.call_count >= 2 else MockMessage() if callb: callb(self.bulb, response) @@ -1152,6 +1152,50 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE +async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None: + """Test we handle failure to update zones.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL + ) + already_migrated_config_entry.add_to_hass(hass) + light_strip = _mocked_light_strip() + entity_id = "light.my_bulb" + + class MockPopulateLifxZonesCommand: + """Mock populating the number of zones.""" + + def __init__(self, bulb, **kwargs): + """Init command.""" + self.bulb = bulb + self.call_count = 0 + + def __call__(self, callb=None, *args, **kwargs): + """Call command.""" + self.call_count += 1 + self.bulb.color_zones = [None] * 12 + if callb: + callb(self.bulb, MockMessage()) + + get_color_zones_mock = MockPopulateLifxZonesCommand(light_strip) + light_strip.get_color_zones = get_color_zones_mock + + with _patch_discovery(device=light_strip), _patch_device(device=light_strip): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == SERIAL + assert hass.states.get(entity_id).state == STATE_OFF + # 1 to get the number of zones + # 2 get populate the zones + assert get_color_zones_mock.call_count == 3 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + # 2 get populate the zones + assert get_color_zones_mock.call_count == 5 + + async def test_white_light_fails(hass: HomeAssistant) -> None: """Test we handle failure to power on off.""" already_migrated_config_entry = MockConfigEntry( diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index fd95964e555..2c85dac0bd4 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -26,6 +26,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test light registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} ) @@ -34,7 +35,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index b8d00681112..9fe6c2b60a8 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -29,7 +29,7 @@ class MockRow: ): """Init the fake row.""" self.event_type = event_type - self.shared_data = json.dumps(data, cls=JSONEncoder) + self.event_data = json.dumps(data, cls=JSONEncoder) self.data = data self.time_fired = dt_util.utcnow() self.time_fired_ts = dt_util.utc_to_timestamp(self.time_fired) @@ -42,8 +42,7 @@ class MockRow: self.context_id_bin = ulid_to_bytes_or_none(context.id) if context else None self.state = None self.entity_id = None - self.state_id = None - self.event_id = None + self.row_id = None self.shared_attrs = None self.attributes = None self.context_only = False @@ -64,7 +63,7 @@ def mock_humanify(hass_, rows): entity_name_cache = processor.EntityNameCache(hass_) ent_reg = er.async_get(hass_) event_cache = processor.EventCache({}) - context_lookup = processor.ContextLookup(hass_) + context_lookup = {} logbook_config = hass_.data.get(logbook.DOMAIN, LogbookConfig({}, None, None)) external_events = logbook_config.external_events logbook_run = processor.LogbookRun( diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index c4f2f0a9ee2..c961d40574d 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -352,7 +352,6 @@ def create_state_changed_event_from_old_new( row.context_id_bin = None row.friendly_name = None row.icon = None - row.old_format_icon = None row.context_user_id_bin = None row.context_parent_id_bin = None row.old_state_id = old_state and 1 diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 9b0b3f2221e..9e991795083 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -60,6 +60,31 @@ def listeners_without_writes(listeners: dict[str, int]) -> dict[str, int]: } +async def _async_mock_logbook_platform_with_broken_describe( + hass: HomeAssistant, +) -> None: + class MockLogbookPlatform: + """Mock a logbook platform with broken describe.""" + + @core.callback + def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[ + [str, str, Callable[[Event], dict[str, str]]], None + ], + ) -> None: + """Describe logbook events.""" + + @core.callback + def async_describe_test_event(event: Event) -> dict[str, str]: + """Describe mock logbook event.""" + raise ValueError("Broken") + + async_describe_event("test", "mock_event", async_describe_test_event) + + await logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) + + async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: class MockLogbookPlatform: """Mock a logbook platform.""" @@ -86,6 +111,23 @@ async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: await logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) +async def _async_mock_entity_with_broken_logbook_platform( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> er.RegistryEntry: + """Mock an integration that provides an entity that are described by the logbook that raises.""" + entry = MockConfigEntry(domain="test", data={"first": True}, options=None) + entry.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + platform="test", + domain="sensor", + config_entry=entry, + unique_id="1234", + suggested_object_id="test", + ) + await _async_mock_logbook_platform_with_broken_describe(hass) + return entry + + async def _async_mock_entity_with_logbook_platform( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> er.RegistryEntry: @@ -2039,6 +2081,113 @@ async def test_logbook_stream_match_multiple_entities( ) == listeners_without_writes(init_listeners) +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_match_multiple_entities_one_with_broken_logbook_platform( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logbook stream with a described integration that uses multiple entities. + + One of the entities has a broken logbook platform. + """ + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + entry = await _async_mock_entity_with_broken_logbook_platform(hass, entity_registry) + entity_id = entry.entity_id + hass.states.async_set(entity_id, STATE_ON) + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": [entity_id], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # There are no answers to our initial query + # so we get an empty reply. This is to ensure + # consumers of the api know there are no results + # and its not a failure case. This is useful + # in the frontend so we can tell the user there + # are no results vs waiting for them to appear + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" in msg["event"] + await async_wait_recording_done(hass) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + await async_wait_recording_done(hass) + + hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) + hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) + context = core.Context( + id="01GTDGKBCH00GW0X276W5TEDDD", + user_id="b400facee45711eaa9308bfd3d19e474", + ) + hass.bus.async_fire( + "mock_event", {"entity_id": ["sensor.any", entity_id]}, context=context + ) + hass.bus.async_fire("mock_event", {"entity_id": [f"sensor.any,{entity_id}"]}) + hass.bus.async_fire("mock_event", {"entity_id": ["sensor.no_match", "light.off"]}) + hass.states.async_set(entity_id, STATE_OFF, context=context) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "sensor.test", + "context_domain": "test", + "context_event_type": "mock_event", + "context_user_id": "b400facee45711eaa9308bfd3d19e474", + "state": "off", + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert listeners_without_writes( + hass.bus.async_listeners() + ) == listeners_without_writes(init_listeners) + + assert "Error with test describe event" in caplog.text + + async def test_event_stream_bad_end_time( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index d98e415482d..5197a101bfd 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -24,7 +24,7 @@ async def test_duplicate_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,7 +44,7 @@ async def test_communication_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_luftdaten_config_flow.get_data.side_effect = LuftdatenConnectionError result2 = await hass.config_entries.flow.async_configure( @@ -53,7 +53,7 @@ async def test_communication_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "cannot_connect"} mock_luftdaten_config_flow.get_data.side_effect = None @@ -79,7 +79,7 @@ async def test_invalid_sensor( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_luftdaten_config_flow.validate_sensor.return_value = False result2 = await hass.config_entries.flow.async_configure( @@ -88,7 +88,7 @@ async def test_invalid_sensor( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {CONF_SENSOR_ID: "invalid_sensor"} mock_luftdaten_config_flow.validate_sensor.return_value = True @@ -116,7 +116,7 @@ async def test_step_user( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index 1cb87a36922..e9e86fd9f1b 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -87,18 +87,15 @@ async def test_luftdaten_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.PA assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("sensor.sensor_12345_particulate_matter_10_mm") + entry = entity_registry.async_get("sensor.sensor_12345_pm10") assert entry assert entry.device_id assert entry.unique_id == "12345_P1" - state = hass.states.get("sensor.sensor_12345_particulate_matter_10_mm") + state = hass.states.get("sensor.sensor_12345_pm10") assert state assert state.state == "8.5" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Sensor 12345 Particulate matter 10 μm" - ) + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 PM10" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( @@ -107,18 +104,15 @@ async def test_luftdaten_sensors( ) assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("sensor.sensor_12345_particulate_matter_2_5_mm") + entry = entity_registry.async_get("sensor.sensor_12345_pm2_5") assert entry assert entry.device_id assert entry.unique_id == "12345_P2" - state = hass.states.get("sensor.sensor_12345_particulate_matter_2_5_mm") + state = hass.states.get("sensor.sensor_12345_pm2_5") assert state assert state.state == "4.07" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Sensor 12345 Particulate matter 2.5 μm" - ) + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 PM2.5" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 9518528714b..7f6a1b60511 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,9 +1,9 @@ """Test the Lutron Caseta config flow.""" import asyncio +from pathlib import Path import ssl from unittest.mock import AsyncMock, patch -import py from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY from pylutron_caseta.smartbridge import Smartbridge import pytest @@ -193,12 +193,11 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: assert result["type"] == "form" -async def test_form_user(hass: HomeAssistant, tmpdir: py.path.local) -> None: +async def test_form_user(hass: HomeAssistant, tmp_path: Path) -> None: """Test we get the form and can pair.""" - - hass.config.config_dir = await hass.async_add_executor_job( - tmpdir.mkdir, "tls_assets" - ) + config_dir = tmp_path / "tls_assets" + await hass.async_add_executor_job(config_dir.mkdir) + hass.config.config_dir = str(config_dir) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -244,14 +243,11 @@ async def test_form_user(hass: HomeAssistant, tmpdir: py.path.local) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_pairing_fails( - hass: HomeAssistant, tmpdir: py.path.local -) -> None: +async def test_form_user_pairing_fails(hass: HomeAssistant, tmp_path: Path) -> None: """Test we get the form and we handle pairing failure.""" - - hass.config.config_dir = await hass.async_add_executor_job( - tmpdir.mkdir, "tls_assets" - ) + config_dir = tmp_path / "tls_assets" + await hass.async_add_executor_job(config_dir.mkdir) + hass.config.config_dir = str(config_dir) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -292,13 +288,12 @@ async def test_form_user_pairing_fails( async def test_form_user_reuses_existing_assets_when_pairing_again( - hass: HomeAssistant, tmpdir: py.path.local + hass: HomeAssistant, tmp_path: Path ) -> None: """Test the tls assets saved on disk are reused when pairing again.""" - - hass.config.config_dir = await hass.async_add_executor_job( - tmpdir.mkdir, "tls_assets" - ) + config_dir = tmp_path / "tls_assets" + await hass.async_add_executor_job(config_dir.mkdir) + hass.config.config_dir = str(config_dir) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -394,13 +389,12 @@ async def test_form_user_reuses_existing_assets_when_pairing_again( async def test_zeroconf_host_already_configured( - hass: HomeAssistant, tmpdir: py.path.local + hass: HomeAssistant, tmp_path: Path ) -> None: """Test starting a flow from discovery when the host is already configured.""" - - hass.config.config_dir = await hass.async_add_executor_job( - tmpdir.mkdir, "tls_assets" - ) + config_dir = tmp_path / "tls_assets" + await hass.async_add_executor_job(config_dir.mkdir) + hass.config.config_dir = str(config_dir) config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "1.1.1.1"}) @@ -479,12 +473,11 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "source", (config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT) ) -async def test_zeroconf(hass: HomeAssistant, source, tmpdir: py.path.local) -> None: +async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: """Test starting a flow from discovery.""" - - hass.config.config_dir = await hass.async_add_executor_job( - tmpdir.mkdir, "tls_assets" - ) + config_dir = tmp_path / "tls_assets" + await hass.async_add_executor_job(config_dir.mkdir) + hass.config.config_dir = str(config_dir) result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 4f4ee08f3bc..8390370d16d 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -18,9 +18,10 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.components.lutron_caseta.models import LutronCasetaData from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry from tests.components.logbook.common import MockRow, mock_humanify @@ -78,3 +79,134 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: assert event1["name"] == "Dining Room Pico" assert event1["domain"] == DOMAIN assert event1["message"] == "press stop" + + +async def test_humanify_lutron_caseta_button_event_integration_not_loaded( + hass: HomeAssistant, +) -> None: + """Test humanifying lutron_caseta_button_events when the integration fails to load.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", + }, + unique_id="abc", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + return_value=MockBridge(can_connect=True), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + for device in device_registry.devices.values(): + if device.config_entries == {config_entry.entry_id}: + dr_device_id = device.id + break + + assert dr_device_id is not None + (event1,) = mock_humanify( + hass, + [ + MockRow( + LUTRON_CASETA_BUTTON_EVENT, + { + ATTR_SERIAL: "68551522", + ATTR_DEVICE_ID: dr_device_id, + ATTR_TYPE: "Pico3ButtonRaiseLower", + ATTR_LEAP_BUTTON_NUMBER: 1, + ATTR_BUTTON_NUMBER: 1, + ATTR_DEVICE_NAME: "Pico", + ATTR_AREA_NAME: "Dining Room", + ATTR_ACTION: "press", + }, + ), + ], + ) + + assert event1["name"] == "Dining Room Pico" + assert event1["domain"] == DOMAIN + assert event1["message"] == "press stop" + + +async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> None: + """Test humanifying lutron_caseta_button_events from an RA3 hub.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + await async_setup_integration(hass, MockBridge) + + registry = dr.async_get(hass) + keypad = registry.async_get_device( + identifiers={(DOMAIN, 66286451)}, connections=set() + ) + assert keypad + + (event1,) = mock_humanify( + hass, + [ + MockRow( + LUTRON_CASETA_BUTTON_EVENT, + { + ATTR_SERIAL: "66286451", + ATTR_DEVICE_ID: keypad.id, + ATTR_TYPE: keypad.model, + ATTR_LEAP_BUTTON_NUMBER: 3, + ATTR_BUTTON_NUMBER: 3, + ATTR_DEVICE_NAME: "Keypad", + ATTR_AREA_NAME: "Breakfast", + ATTR_ACTION: "press", + }, + ), + ], + ) + + assert event1["name"] == "Breakfast Keypad" + assert event1["domain"] == DOMAIN + assert event1["message"] == "press Kitchen Pendants" + + +async def test_humanify_lutron_caseta_button_unknown_type(hass: HomeAssistant) -> None: + """Test humanifying lutron_caseta_button_events with an unknown type.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + await async_setup_integration(hass, MockBridge) + + registry = dr.async_get(hass) + keypad = registry.async_get_device( + identifiers={(DOMAIN, 66286451)}, connections=set() + ) + assert keypad + + (event1,) = mock_humanify( + hass, + [ + MockRow( + LUTRON_CASETA_BUTTON_EVENT, + { + ATTR_SERIAL: "66286451", + ATTR_DEVICE_ID: "removed", + ATTR_TYPE: keypad.model, + ATTR_LEAP_BUTTON_NUMBER: 3, + ATTR_BUTTON_NUMBER: 3, + ATTR_DEVICE_NAME: "Keypad", + ATTR_AREA_NAME: "Breakfast", + ATTR_ACTION: "press", + }, + ), + ], + ) + + assert event1["name"] == "Breakfast Keypad" + assert event1["domain"] == DOMAIN + assert event1["message"] == "press Error retrieving button description" diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 77c28982ef1..e0cae290e2b 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -1,22 +1,124 @@ """The tests for the mailbox component.""" +from datetime import datetime from hashlib import sha1 from http import HTTPStatus +from typing import Any +from aiohttp.test_utils import TestClient import pytest from homeassistant.bootstrap import async_setup_component import homeassistant.components.mailbox as mailbox +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util + +from tests.common import MockModule, mock_integration, mock_platform +from tests.typing import ClientSessionGenerator + +MAILBOX_NAME = "TestMailbox" +MEDIA_DATA = b"3f67c4ea33b37d1710f" +MESSAGE_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + +def _create_message(idx: int) -> dict[str, Any]: + """Create a sample message.""" + msgtime = dt_util.as_timestamp(datetime(2010, 12, idx + 1, 13, 17, 00)) + msgtxt = f"Message {idx + 1}. {MESSAGE_TEXT}" + msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() + return { + "info": { + "origtime": int(msgtime), + "callerid": "John Doe <212-555-1212>", + "duration": "10", + }, + "text": msgtxt, + "sha": msgsha, + } + + +class TestMailbox(mailbox.Mailbox): + """Test Mailbox, with 10 sample messages.""" + + def __init__(self, hass: HomeAssistant, name: str) -> None: + """Initialize Test mailbox.""" + super().__init__(hass, name) + self._messages: dict[str, dict[str, Any]] = {} + for idx in range(0, 10): + msg = _create_message(idx) + msgsha = msg["sha"] + self._messages[msgsha] = msg + + @property + def media_type(self) -> str: + """Return the supported media type.""" + return mailbox.CONTENT_TYPE_MPEG + + @property + def can_delete(self) -> bool: + """Return if messages can be deleted.""" + return True + + @property + def has_media(self) -> bool: + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid: str) -> bytes: + """Return the media blob for the msgid.""" + if msgid not in self._messages: + raise mailbox.StreamError("Message not found") + + return MEDIA_DATA + + async def async_get_messages(self) -> list[dict[str, Any]]: + """Return a list of the current messages.""" + return sorted( + self._messages.values(), + key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return] + reverse=True, + ) + + async def async_delete(self, msgid: str) -> bool: + """Delete the specified messages.""" + if msgid in self._messages: + del self._messages[msgid] + self.async_update() + return True + + +class MockMailbox: + """A mock mailbox platform.""" + + async def async_get_handler( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> mailbox.Mailbox: + """Set up the Test mailbox.""" + return TestMailbox(hass, MAILBOX_NAME) @pytest.fixture -def mock_http_client(hass, hass_client): +def mock_mailbox(hass: HomeAssistant) -> None: + """Mock mailbox.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.mailbox", MockMailbox()) + + +@pytest.fixture +async def mock_http_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_mailbox: None +) -> TestClient: """Start the Home Assistant HTTP component.""" - config = {mailbox.DOMAIN: {"platform": "demo"}} - hass.loop.run_until_complete(async_setup_component(hass, mailbox.DOMAIN, config)) - return hass.loop.run_until_complete(hass_client()) + assert await async_setup_component( + hass, mailbox.DOMAIN, {mailbox.DOMAIN: {"platform": "test"}} + ) + return await hass_client() -async def test_get_platforms_from_mailbox(mock_http_client) -> None: +async def test_get_platforms_from_mailbox(mock_http_client: TestClient) -> None: """Get platforms from mailbox.""" url = "/api/mailbox/platforms" @@ -24,12 +126,12 @@ async def test_get_platforms_from_mailbox(mock_http_client) -> None: assert req.status == HTTPStatus.OK result = await req.json() assert len(result) == 1 - assert result[0].get("name") == "DemoMailbox" + assert result[0].get("name") == "TestMailbox" -async def test_get_messages_from_mailbox(mock_http_client) -> None: +async def test_get_messages_from_mailbox(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" - url = "/api/mailbox/messages/DemoMailbox" + url = "/api/mailbox/messages/TestMailbox" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK @@ -37,20 +139,20 @@ async def test_get_messages_from_mailbox(mock_http_client) -> None: assert len(result) == 10 -async def test_get_media_from_mailbox(mock_http_client) -> None: +async def test_get_media_from_mailbox(mock_http_client: TestClient) -> None: """Get audio from mailbox.""" - mp3sha = "3f67c4ea33b37d1710f772a26dd3fb43bb159d50" + mp3sha = "7cad61312c7b66f619295be2da8c7ac73b4968f1" msgtxt = "Message 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. " msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() - url = f"/api/mailbox/media/DemoMailbox/{msgsha}" + url = f"/api/mailbox/media/TestMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK data = await req.read() assert sha1(data).hexdigest() == mp3sha -async def test_delete_from_mailbox(mock_http_client) -> None: +async def test_delete_from_mailbox(mock_http_client: TestClient) -> None: """Get audio from mailbox.""" msgtxt1 = "Message 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. " msgtxt2 = "Message 3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. " @@ -58,18 +160,18 @@ async def test_delete_from_mailbox(mock_http_client) -> None: msgsha2 = sha1(msgtxt2.encode("utf-8")).hexdigest() for msg in [msgsha1, msgsha2]: - url = f"/api/mailbox/delete/DemoMailbox/{msg}" + url = f"/api/mailbox/delete/TestMailbox/{msg}" req = await mock_http_client.delete(url) assert req.status == HTTPStatus.OK - url = "/api/mailbox/messages/DemoMailbox" + url = "/api/mailbox/messages/TestMailbox" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK result = await req.json() assert len(result) == 8 -async def test_get_messages_from_invalid_mailbox(mock_http_client) -> None: +async def test_get_messages_from_invalid_mailbox(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" url = "/api/mailbox/messages/mailbox.invalid_mailbox" @@ -77,7 +179,7 @@ async def test_get_messages_from_invalid_mailbox(mock_http_client) -> None: assert req.status == HTTPStatus.NOT_FOUND -async def test_get_media_from_invalid_mailbox(mock_http_client) -> None: +async def test_get_media_from_invalid_mailbox(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" msgsha = "0000000000000000000000000000000000000000" url = f"/api/mailbox/media/mailbox.invalid_mailbox/{msgsha}" @@ -86,16 +188,16 @@ async def test_get_media_from_invalid_mailbox(mock_http_client) -> None: assert req.status == HTTPStatus.NOT_FOUND -async def test_get_media_from_invalid_msgid(mock_http_client) -> None: +async def test_get_media_from_invalid_msgid(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" msgsha = "0000000000000000000000000000000000000000" - url = f"/api/mailbox/media/DemoMailbox/{msgsha}" + url = f"/api/mailbox/media/TestMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_delete_from_invalid_mailbox(mock_http_client) -> None: +async def test_delete_from_invalid_mailbox(mock_http_client: TestClient) -> None: """Get audio from mailbox.""" msgsha = "0000000000000000000000000000000000000000" url = f"/api/mailbox/delete/mailbox.invalid_mailbox/{msgsha}" diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 549fa995179..0df1114bf30 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -1506,3 +1506,24 @@ async def test_state_changes_are_published_to_mqtt( mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_DISARMED, 0, True ) + + +async def test_no_mqtt(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test publishing of MQTT messages when state changes.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + alarm_control_panel.DOMAIN: { + "platform": "manual_mqtt", + "name": "test", + "state_topic": "alarm/state", + "command_topic": "alarm/command", + } + }, + ) + await hass.async_block_till_done() + + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id) is None + assert "MQTT integration is not available" in caplog.text diff --git a/tests/components/matter/fixtures/nodes/window-covering.json b/tests/components/matter/fixtures/nodes/window-covering.json new file mode 100644 index 00000000000..5ab0d497278 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering.json @@ -0,0 +1,353 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-29T08:23:30.740085", + "last_interview": "2023-03-29T08:23:30.740087", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, 30, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 54, 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/30/0": [], + "0/30/65532": 0, + "0/30/65533": 1, + "0/30/65528": [], + "0/30/65529": [], + "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 2 + } + ], + "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": "Eliteu", + "0/40/2": 4895, + "0/40/3": "Longan link WNCV DA01", + "0/40/4": 12288, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.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": "3c70c712bd34e54acebd1a8371f56f7d", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "7630EF9998EDF03C", + "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": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/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/45/0": 0, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 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": "TE9OR0FOLUlPVA==", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "TE9OR0FOLUlPVA==", + "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": "hPcDB5/k", + "IPv4Addresses": ["wKgIhg=="], + "IPv6Addresses": [ + "/oAAAAAAAACG9wP//gef5A==", + "JA4DsgZ+bsCG9wP//gef5A==" + ], + "type": 1 + } + ], + "0/51/1": 35, + "0/51/2": 123, + "0/51/3": 0, + "0/51/4": 6, + "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/54/0": "mJfMGB1w", + "0/54/1": 0, + "0/54/2": 3, + "0/54/3": 1, + "0/54/4": -36, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 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": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "fabricIndex": 2 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycUBZIG4P1iqI0kFQEYJgRBkLUrJgXBw5YtNwYnFAWSBuD9YqiNJBUBGCQHASQIATAJQQRruztKRDFfiVjMY19sSsnKqBZJlZrQ/ClUtTYatvOZxbTC53iCqhwHaIJthMWs7ICwtSX1Vr5lGkzDXQjH/oQ6Nwo1ASkBGCQCYDAEFJd2wRMLYsFFA1PRCdMviVipH3OWMAUUl3bBEwtiwUUDU9EJ0y+JWKkfc5YYMAtASJa3FJ84kws+OOWNEMgRvcZA/d0AJVmmoqoWrorxxfpVKujZuN8Kc193rwBckfxd69s3OS1y8HCZTtooCemIpBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEUsyszWxq0GlTQM3iyuL9LgBWj+6CZkKd0F887fdD5kIoNnM2Ew4HTXj6FmzQovu8+yxq9PtNv2e3Rb6Sl4B7RTcKNQEpARgkAmAwBBRQgiuTWL+gqw+f9Etus1wVMBtFgTAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQFyHXux9szIosC1gP+/1/7BX3PfGaX2GF172oHSAoMXnLJ7OawkzgWIykEj7oRIjKv3XRR27y3KhV83817SfCOkY" + ], + "0/62/5": 2, + "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": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "type": 514, + "revision": 1 + } + ], + "1/29/1": [3, 4, 29, 30, 64, 65, 258], + "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/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "1/64/65532": 0, + "1/64/65533": 1, + "1/64/65528": [], + "1/64/65529": [], + "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/65/0": [], + "1/65/65532": 0, + "1/65/65533": 1, + "1/65/65528": [], + "1/65/65529": [], + "1/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/258/0": 0, + "1/258/1": 0, + "1/258/3": 0, + "1/258/5": 0, + "1/258/7": 11, + "1/258/8": 100, + "1/258/10": 0, + "1/258/11": 0, + "1/258/13": 0, + "1/258/14": 4900, + "1/258/16": 0, + "1/258/17": 65535, + "1/258/23": 0, + "1/258/65532": 13, + "1/258/65533": 5, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 4, 5, 18, 19], + "1/258/65531": [ + 0, 1, 3, 5, 7, 8, 10, 11, 13, 14, 16, 17, 23, 65528, 65529, 65531, 65532, + 65533 + ] + } +} diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index 9adafa5814b..eddf6506bfd 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -193,6 +193,7 @@ async def test_supervisor_discovery( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -232,6 +233,7 @@ async def test_supervisor_discovery_addon_info_failed( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -262,6 +264,7 @@ async def test_clean_supervisor_discovery_on_user_create( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -324,6 +327,7 @@ async def test_abort_supervisor_discovery_with_existing_entry( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -353,6 +357,7 @@ async def test_abort_supervisor_discovery_with_existing_flow( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -379,6 +384,7 @@ async def test_abort_supervisor_discovery_for_other_addon( }, name="Other Matter Server", slug="other_addon", + uuid="1234", ), ) @@ -404,6 +410,7 @@ async def test_supervisor_discovery_addon_not_running( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -452,6 +459,7 @@ async def test_supervisor_discovery_addon_not_installed( config=ADDON_DISCOVERY_INFO, name="Matter Server", slug=ADDON_SLUG, + uuid="1234", ), ) diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py new file mode 100644 index 00000000000..15ce8ceea8b --- /dev/null +++ b/tests/components/matter/test_cover.py @@ -0,0 +1,141 @@ +"""Test Matter covers.""" +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 ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@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]) +async def test_cover( + hass: HomeAssistant, + matter_client: MagicMock, + window_covering: MatterNode, +) -> None: + """Test window covering.""" + await hass.services.async_call( + "cover", + "close_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + 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.DownOrClose(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "stop_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + 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.StopMotion(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "open_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + 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.UpOrOpen(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "set_cover_position", + { + "entity_id": "cover.longan_link_wncv_da01", + "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.GoToLiftPercentage(5000), + ) + 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) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + 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) + 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") + 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) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + assert state + assert state.attributes["current_position"] == 0 + assert state.state == STATE_CLOSED diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index eb1e52bf335..b7bf35ab2f8 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,6 +21,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index ba6b99b2e3e..98922d7d0a4 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -25,6 +25,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test media_player registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} ) @@ -33,7 +34,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/microsoft_face/test_init.py b/tests/components/microsoft_face/test_init.py index 5f61192033c..a33d9fcfdec 100644 --- a/tests/components/microsoft_face/test_init.py +++ b/tests/components/microsoft_face/test_init.py @@ -25,6 +25,12 @@ from tests.common import assert_setup_component, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def create_group(hass, name): """Create a new person group. diff --git a/tests/components/microsoft_face_detect/test_image_processing.py b/tests/components/microsoft_face_detect/test_image_processing.py index 835341f0757..349440124ff 100644 --- a/tests/components/microsoft_face_detect/test_image_processing.py +++ b/tests/components/microsoft_face_detect/test_image_processing.py @@ -26,6 +26,12 @@ CONFIG = { ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def store_mock(): """Mock update store.""" diff --git a/tests/components/microsoft_face_identify/test_image_processing.py b/tests/components/microsoft_face_identify/test_image_processing.py index 85a0ee39105..6581aea835f 100644 --- a/tests/components/microsoft_face_identify/test_image_processing.py +++ b/tests/components/microsoft_face_identify/test_image_processing.py @@ -14,6 +14,12 @@ from tests.components.image_processing import common from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def store_mock(): """Mock update store.""" diff --git a/tests/components/mjpeg/test_config_flow.py b/tests/components/mjpeg/test_config_flow.py index c95f4f1c40f..a60df88b789 100644 --- a/tests/components/mjpeg/test_config_flow.py +++ b/tests/components/mjpeg/test_config_flow.py @@ -36,7 +36,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -81,7 +81,7 @@ async def test_full_flow_with_authentication_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_mjpeg_requests.get( "https://example.com/mjpeg", text="Access Denied!", status_code=401 @@ -97,7 +97,7 @@ async def test_full_flow_with_authentication_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"username": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 @@ -141,7 +141,7 @@ async def test_connection_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" # Test connectione error on MJPEG url mock_mjpeg_requests.get( @@ -157,7 +157,7 @@ async def test_connection_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"mjpeg_url": "cannot_connect"} assert len(mock_setup_entry.mock_calls) == 0 @@ -180,7 +180,7 @@ async def test_connection_error( ) assert result3.get("type") == FlowResultType.FORM - assert result3.get("step_id") == SOURCE_USER + assert result3.get("step_id") == "user" assert result3.get("errors") == {"still_image_url": "cannot_connect"} assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 65e21c2718e..02c9ace7cd4 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE @@ -28,6 +29,12 @@ from tests.components.conversation.conftest import mock_agent mock_agent = mock_agent +@pytest.fixture +async def homeassistant(hass): + """Load the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def encrypt_payload(secret_key, payload, encode_json=True): """Return a encrypted payload given a key and dictionary of data.""" try: @@ -1014,7 +1021,7 @@ async def test_reregister_sensor( async def test_webhook_handle_conversation_process( - hass: HomeAssistant, create_registrations, webhook_client, mock_agent + hass: HomeAssistant, homeassistant, create_registrations, webhook_client, mock_agent ) -> None: """Test that we can converse.""" webhook_client.server.app.router._frozen = False diff --git a/tests/components/moon/test_config_flow.py b/tests/components/moon/test_config_flow.py index e7ee1cceefd..cd2ab94fefc 100644 --- a/tests/components/moon/test_config_flow.py +++ b/tests/components/moon/test_config_flow.py @@ -19,7 +19,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index a7bf537add6..5cb6244010e 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -79,6 +79,7 @@ async def test_hassio_success(hass: HomeAssistant) -> None: config={"addon": "motionEye", "url": TEST_URL}, name="motionEye", slug="motioneye", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -357,6 +358,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: config={"addon": "motionEye", "url": TEST_URL}, name="motionEye", slug="motioneye", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -376,6 +378,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: config={"addon": "motionEye", "url": TEST_URL}, name="motionEye", slug="motioneye", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -396,6 +399,7 @@ async def test_hassio_abort_if_already_in_progress(hass: HomeAssistant) -> None: config={"addon": "motionEye", "url": TEST_URL}, name="motionEye", slug="motioneye", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -412,6 +416,7 @@ async def test_hassio_clean_up_on_user_flow(hass: HomeAssistant) -> None: config={"addon": "motionEye", "url": TEST_URL}, name="motionEye", slug="motioneye", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 79c06d7a5f3..a30a15d5098 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -178,10 +178,10 @@ async def test_fail_setup_without_state_or_command_topic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_update_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test updating with via state topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() entity_id = "alarm_control_panel.test" @@ -206,10 +206,10 @@ async def test_update_state_via_state_topic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_ignore_update_state_if_unknown_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test ignoring updates via state topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() entity_id = "alarm_control_panel.test" @@ -233,12 +233,12 @@ async def test_ignore_update_state_if_unknown_via_state_topic( ) async def test_publish_mqtt_no_code( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when no code is configured.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( alarm_control_panel.DOMAIN, @@ -264,12 +264,12 @@ async def test_publish_mqtt_no_code( ) async def test_publish_mqtt_with_code( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when code is configured.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -318,12 +318,12 @@ async def test_publish_mqtt_with_code( ) async def test_publish_mqtt_with_remote_code( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when remode code is configured.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -363,12 +363,12 @@ async def test_publish_mqtt_with_remote_code( ) async def test_publish_mqtt_with_remote_code_text( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, service: str, payload: str, ) -> None: """Test publishing of MQTT messages when remote text code is configured.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -460,7 +460,7 @@ async def test_publish_mqtt_with_remote_code_text( ) async def test_publish_mqtt_with_code_required_false( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, service: str, payload: str, ) -> None: @@ -469,7 +469,7 @@ async def test_publish_mqtt_with_code_required_false( code_arm_required = False / code_disarm_required = False / code_trigger_required = False """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # No code provided, should publish await hass.services.async_call( @@ -518,13 +518,13 @@ async def test_publish_mqtt_with_code_required_false( ], ) async def test_disarm_publishes_mqtt_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test publishing of MQTT messages while disarmed. When command_template set to output json """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_alarm_disarm(hass, "0123") mqtt_mock.async_publish.assert_called_once_with( @@ -553,10 +553,10 @@ async def test_disarm_publishes_mqtt_with_template( ], ) async def test_update_state_via_state_topic_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test updating with template_value via state topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNKNOWN @@ -576,10 +576,10 @@ async def test_update_state_via_state_topic_template( ], ) async def test_attributes_code_number( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("alarm_control_panel.test") assert ( @@ -599,10 +599,10 @@ async def test_attributes_code_number( ], ) async def test_attributes_remote_code_number( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("alarm_control_panel.test") assert ( @@ -620,10 +620,10 @@ async def test_attributes_remote_code_number( ], ) async def test_attributes_code_text( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("alarm_control_panel.test") assert ( @@ -634,70 +634,70 @@ async def test_attributes_code_text( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN + hass, mqtt_mock_entry, alarm_control_panel.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, MQTT_ALARM_ATTRIBUTES_BLOCKED, @@ -705,12 +705,12 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) @@ -718,13 +718,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, @@ -733,13 +733,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, @@ -748,13 +748,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, @@ -785,29 +785,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one alarm per unique_id.""" - await help_test_unique_id( - hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN - ) + await help_test_unique_id(hass, mqtt_mock_entry, alarm_control_panel.DOMAIN) async def test_discovery_removal_alarm( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered alarm_control_panel.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][alarm_control_panel.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, data + hass, mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, data ) async def test_discovery_update_alarm_topic_and_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered alarm_control_panel.""" @@ -832,7 +830,7 @@ async def test_discovery_update_alarm_topic_and_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, config1, @@ -844,7 +842,7 @@ async def test_discovery_update_alarm_topic_and_template( async def test_discovery_update_alarm_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered alarm_control_panel.""" @@ -867,7 +865,7 @@ async def test_discovery_update_alarm_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, config1, @@ -879,7 +877,7 @@ async def test_discovery_update_alarm_template( async def test_discovery_update_unchanged_alarm( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered alarm_control_panel.""" @@ -892,7 +890,7 @@ async def test_discovery_update_unchanged_alarm( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, data1, @@ -903,7 +901,7 @@ async def test_discovery_update_unchanged_alarm( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -915,7 +913,7 @@ async def test_discovery_broken( ) await help_test_discovery_broken( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, alarm_control_panel.DOMAIN, data1, @@ -932,14 +930,14 @@ async def test_discovery_broken( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][alarm_control_panel.DOMAIN], topic, @@ -948,81 +946,81 @@ async def test_encoding_subscribable_topics( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT alarm control panel device registry integration.""" await help_test_entity_device_info_with_connection( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT alarm control panel device registry integration.""" await help_test_entity_device_info_with_identifier( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, alarm_control_panel.SERVICE_ALARM_DISARM, @@ -1055,7 +1053,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -1071,7 +1069,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -1097,21 +1095,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = alarm_control_panel.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 0d3cd695490..c98de3628b8 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -93,11 +93,11 @@ def binary_sensor_platform_only(): ) async def test_setting_sensor_value_expires_availability_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the expiration of the value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE @@ -128,11 +128,11 @@ async def test_setting_sensor_value_expires_availability_topic( ) async def test_setting_sensor_value_expires( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the expiration of the value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("binary_sensor.test") @@ -194,11 +194,11 @@ async def expires_helper(hass: HomeAssistant) -> None: async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test that binary_sensor with expire_after set behaves correctly on discovery and discovery update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "name": "Test", "state_topic": "test-topic", @@ -291,10 +291,10 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( ], ) async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") @@ -330,11 +330,11 @@ async def test_setting_sensor_value_via_mqtt_message( ) async def test_invalid_sensor_value_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") @@ -375,10 +375,10 @@ async def test_invalid_sensor_value_via_mqtt_message( ], ) async def test_setting_sensor_value_via_mqtt_message_and_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -410,11 +410,11 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( ) async def test_setting_sensor_value_via_mqtt_message_and_template2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -452,11 +452,11 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( ) async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test processing a raw value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -487,10 +487,10 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ ], ) async def test_setting_sensor_value_via_mqtt_message_empty_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -505,27 +505,44 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "device_class"), [ - { - mqtt.DOMAIN: { - binary_sensor.DOMAIN: { - "name": "test", - "device_class": "motion", - "state_topic": "test-topic", + ( + { + mqtt.DOMAIN: { + binary_sensor.DOMAIN: { + "name": "test", + "device_class": "motion", + "state_topic": "test-topic", + } } - } - } + }, + "motion", + ), + ( + { + mqtt.DOMAIN: { + binary_sensor.DOMAIN: { + "name": "test", + "device_class": None, + "state_topic": "test-topic", + } + } + }, + None, + ), ], ) async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_class: str | None, ) -> None: - """Test the setting of a valid sensor class.""" - await mqtt_mock_entry_no_yaml_config() + """Test the setting of a valid sensor class and ignoring an empty device_class.""" + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test") - assert state.attributes.get("device_class") == "motion" + assert state.attributes.get("device_class") == device_class @pytest.mark.parametrize( @@ -545,49 +562,49 @@ async def test_valid_device_class( async def test_invalid_device_class( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid sensor class.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN + hass, mqtt_mock_entry, binary_sensor.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG ) @@ -607,10 +624,10 @@ async def test_custom_availability_payload( ], ) async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test force update option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] @@ -647,10 +664,10 @@ async def test_force_update_disabled( ], ) async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test force update option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] @@ -688,10 +705,10 @@ async def test_force_update_enabled( ], ) async def test_off_delay( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test off_delay option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] @@ -722,21 +739,21 @@ async def test_off_delay( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) @@ -744,13 +761,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, @@ -759,13 +776,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, @@ -774,13 +791,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, @@ -809,29 +826,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - await help_test_unique_id( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN - ) + await help_test_unique_id(hass, mqtt_mock_entry, binary_sensor.DOMAIN) async def test_discovery_removal_binary_sensor( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered binary_sensor.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][binary_sensor.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, data + hass, mqtt_mock_entry, caplog, binary_sensor.DOMAIN, data ) async def test_discovery_update_binary_sensor_topic_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered binary_sensor.""" @@ -858,7 +873,7 @@ async def test_discovery_update_binary_sensor_topic_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, config1, @@ -870,7 +885,7 @@ async def test_discovery_update_binary_sensor_topic_template( async def test_discovery_update_binary_sensor_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered binary_sensor.""" @@ -895,7 +910,7 @@ async def test_discovery_update_binary_sensor_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, config1, @@ -920,7 +935,7 @@ async def test_discovery_update_binary_sensor_template( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -929,7 +944,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][binary_sensor.DOMAIN], topic, @@ -941,7 +956,7 @@ async def test_encoding_subscribable_topics( async def test_discovery_update_unchanged_binary_sensor( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered binary_sensor.""" @@ -954,7 +969,7 @@ async def test_discovery_update_unchanged_binary_sensor( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, data1, @@ -965,7 +980,7 @@ async def test_discovery_update_unchanged_binary_sensor( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -973,7 +988,7 @@ async def test_discovery_broken( data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' await help_test_discovery_broken( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, binary_sensor.DOMAIN, data1, @@ -982,81 +997,81 @@ async def test_discovery_broken( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT binary sensor device registry integration.""" await help_test_entity_device_info_with_connection( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT binary sensor device registry integration.""" await help_test_entity_device_info_with_identifier( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, binary_sensor.DOMAIN, DEFAULT_CONFIG, None, @@ -1108,7 +1123,7 @@ async def test_reloadable( ) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, @@ -1121,7 +1136,7 @@ async def test_cleanup_triggers_and_restoring_state( """Test cleanup old triggers at reloading and restoring the state.""" freezer.move_to("2022-02-02 12:01:00+01:00") - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic1", payload1) state = hass.states.get("binary_sensor.test1") @@ -1164,7 +1179,7 @@ async def test_cleanup_triggers_and_restoring_state( ) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" @@ -1183,28 +1198,28 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) mock_restore_cache(hass, (fake_state,)) - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("binary_sensor.test3") assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = binary_sensor.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 37636ff4bfd..e99182323c8 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -73,10 +73,10 @@ def button_platform_only(): ], ) async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending MQTT commands.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("button.test_button") assert state.state == STATE_UNKNOWN @@ -113,10 +113,10 @@ async def test_sending_mqtt_commands( ], ) async def test_command_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of MQTT commands through a command template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("button.test") assert state.state == STATE_UNKNOWN @@ -137,26 +137,26 @@ async def test_command_template( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN + hass, mqtt_mock_entry, button.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -170,7 +170,7 @@ async def test_default_availability_payload( } await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, button.DOMAIN, config, True, @@ -180,7 +180,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -195,7 +195,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, button.DOMAIN, config, True, @@ -205,41 +205,41 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG, None + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG, None ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, button.DOMAIN, DEFAULT_CONFIG, @@ -248,13 +248,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, button.DOMAIN, DEFAULT_CONFIG, @@ -263,13 +263,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, button.DOMAIN, DEFAULT_CONFIG, @@ -298,27 +298,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one button per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, button.DOMAIN) async def test_discovery_removal_button( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered button.""" data = '{ "name": "test", "command_topic": "test_topic" }' await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, data + hass, mqtt_mock_entry, caplog, button.DOMAIN, data ) async def test_discovery_update_button( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered button.""" @@ -329,7 +329,7 @@ async def test_discovery_update_button( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, button.DOMAIN, config1, @@ -339,7 +339,7 @@ async def test_discovery_update_button( async def test_discovery_update_unchanged_button( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered button.""" @@ -353,7 +353,7 @@ async def test_discovery_update_unchanged_button( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, button.DOMAIN, data1, @@ -364,69 +364,69 @@ async def test_discovery_update_unchanged_button( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, button.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT button device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT button device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, button.DOMAIN, DEFAULT_CONFIG, button.SERVICE_PRESS, @@ -450,11 +450,11 @@ async def test_entity_debug_info_message( ], ) async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device_class option with invalid value.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize( @@ -477,16 +477,21 @@ async def test_invalid_device_class( "name": "Test 3", "command_topic": "test-topic", }, + { + "name": "Test 4", + "command_topic": "test-topic", + "device_class": None, + }, ] } } ], ) async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device_class option with valid values.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("button.test_1") assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE @@ -504,7 +509,7 @@ async def test_valid_device_class( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -518,7 +523,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -542,21 +547,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = button.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = button.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 27d575ce4e8..8bb21f5eb51 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -62,11 +62,11 @@ def camera_platform_only(): async def test_run_camera_setup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test that it fetches the given payload.""" topic = "test/camera" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -96,11 +96,11 @@ async def test_run_camera_setup( async def test_run_camera_b64_encoded( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test that it fetches the given encoded payload.""" topic = "test/camera" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -132,12 +132,12 @@ async def test_run_camera_b64_encoded( async def test_camera_b64_encoded_with_availability( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test availability works if b64 encoding is turned on.""" topic = "test/camera" topic_availability = "test/camera_availability" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Make sure we are available async_fire_mqtt_message(hass, topic_availability, "online") @@ -155,58 +155,58 @@ async def test_camera_b64_encoded_with_availability( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN + hass, mqtt_mock_entry, camera.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG, MQTT_CAMERA_ATTRIBUTES_BLOCKED, @@ -214,23 +214,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, camera.DOMAIN, DEFAULT_CONFIG, @@ -239,13 +239,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, camera.DOMAIN, DEFAULT_CONFIG, @@ -254,13 +254,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, camera.DOMAIN, DEFAULT_CONFIG, @@ -289,27 +289,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one camera per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, camera.DOMAIN) async def test_discovery_removal_camera( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered camera.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][camera.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, data + hass, mqtt_mock_entry, caplog, camera.DOMAIN, data ) async def test_discovery_update_camera( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered camera.""" @@ -317,13 +317,13 @@ async def test_discovery_update_camera( config2 = {"name": "Milk", "topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, camera.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_camera( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered camera.""" @@ -333,7 +333,7 @@ async def test_discovery_update_unchanged_camera( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, camera.DOMAIN, data1, @@ -344,7 +344,7 @@ async def test_discovery_update_unchanged_camera( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -352,53 +352,53 @@ async def test_discovery_broken( data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, camera.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT camera device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT camera device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"], @@ -406,21 +406,21 @@ async def test_entity_id_update_subscriptions( async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, camera.DOMAIN, DEFAULT_CONFIG, None, @@ -441,21 +441,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = camera.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = camera.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 36894ba2cdc..384f03a317c 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -104,10 +104,10 @@ def climate_platform_only(): @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_params( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the initial parameters.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -133,11 +133,11 @@ async def test_setup_params( async def test_preset_none_in_preset_modes( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert "Invalid config for [mqtt]: not a valid value" in caplog.text @@ -211,10 +211,10 @@ async def test_preset_modes_deprecation_guard( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the supported_features.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) support = ( @@ -232,10 +232,10 @@ async def test_supported_features( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_get_hvac_modes( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that the operation list returns the correct modes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) modes = state.attributes.get("hvac_modes") @@ -252,14 +252,14 @@ async def test_get_hvac_modes( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation_bad_attr_and_state( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting operation mode without required attribute. Also check the state. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -275,10 +275,10 @@ async def test_set_operation_bad_attr_and_state( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -298,11 +298,11 @@ async def test_set_operation( ], ) async def test_set_operation_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in pessimistic mode.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "unknown" @@ -331,10 +331,10 @@ async def test_set_operation_pessimistic( ], ) async def test_set_operation_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -364,10 +364,10 @@ async def test_set_operation_optimistic( ], ) async def test_set_operation_with_power_command( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode with power command enabled.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -391,11 +391,11 @@ async def test_set_operation_with_power_command( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting fan mode without required attribute.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -417,10 +417,10 @@ async def test_set_fan_mode_bad_attr( ], ) async def test_set_fan_mode_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in pessimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") is None @@ -449,10 +449,10 @@ async def test_set_fan_mode_pessimistic( ], ) async def test_set_fan_mode_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -472,10 +472,10 @@ async def test_set_fan_mode_optimistic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -488,11 +488,11 @@ async def test_set_fan_mode( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting swing mode without required attribute.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -514,10 +514,10 @@ async def test_set_swing_mode_bad_attr( ], ) async def test_set_swing_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in pessimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None @@ -546,10 +546,10 @@ async def test_set_swing_pessimistic( ], ) async def test_set_swing_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -569,10 +569,10 @@ async def test_set_swing_optimistic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of new swing mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -584,10 +584,10 @@ async def test_set_swing( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -622,10 +622,10 @@ async def test_set_target_temperature( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_humidity( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -647,10 +647,10 @@ async def test_set_target_humidity( ], ) async def test_set_target_temperature_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") is None @@ -679,10 +679,10 @@ async def test_set_target_temperature_pessimistic( ], ) async def test_set_target_temperature_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature optimistic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -702,10 +702,10 @@ async def test_set_target_temperature_optimistic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature_low_high( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_set_temperature( hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE @@ -733,10 +733,10 @@ async def test_set_target_temperature_low_high( ], ) async def test_set_target_temperature_low_highpessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") is None @@ -784,10 +784,10 @@ async def test_set_target_temperature_low_highpessimistic( ], ) async def test_set_target_temperature_low_high_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature optimistic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") == 21 @@ -829,10 +829,10 @@ async def test_set_target_temperature_low_high_optimistic( ], ) async def test_set_target_humidity_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity optimistic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -860,10 +860,10 @@ async def test_set_target_humidity_optimistic( ], ) async def test_set_target_humidity_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -891,10 +891,10 @@ async def test_set_target_humidity_pessimistic( ], ) async def test_receive_mqtt_temperature( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test getting the current temperature via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "current_temperature", "47") state = hass.states.get(ENTITY_CLIMATE) @@ -912,10 +912,10 @@ async def test_receive_mqtt_temperature( ], ) async def test_receive_mqtt_humidity( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test getting the current humidity via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "current_humidity", "35") state = hass.states.get(ENTITY_CLIMATE) @@ -933,10 +933,10 @@ async def test_receive_mqtt_humidity( ], ) async def test_handle_target_humidity_received( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -952,10 +952,10 @@ async def test_handle_target_humidity_received( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"action_topic": "action"},))], ) async def test_handle_action_received( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test getting the action received via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Cycle through valid modes and also check for wrong input such as "None" (str(None)) async_fire_mqtt_message(hass, "action", "None") @@ -975,11 +975,11 @@ async def test_handle_action_received( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_preset_mode_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -1032,11 +1032,11 @@ async def test_set_preset_mode_optimistic( ) async def test_set_preset_mode_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -1089,11 +1089,11 @@ async def test_set_preset_mode_explicit_optimistic( ) async def test_set_preset_mode_pessimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -1141,10 +1141,10 @@ async def test_set_preset_mode_pessimistic( ], ) async def test_set_aux_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating in pessimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1168,10 +1168,10 @@ async def test_set_aux_pessimistic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_aux( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1189,39 +1189,39 @@ async def test_set_aux( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN + hass, mqtt_mock_entry, climate.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) @@ -1244,11 +1244,11 @@ async def test_custom_availability_payload( ) async def test_get_target_temperature_low_high_with_templates( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting temperature high/low with templates.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) @@ -1351,11 +1351,11 @@ async def test_get_target_temperature_low_high_with_templates( ) async def test_get_with_templates( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting various attributes with templates.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1507,11 +1507,11 @@ async def test_get_with_templates( ) async def test_set_and_templates( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting various attributes with templates.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fan Mode await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE) @@ -1589,10 +1589,10 @@ async def test_set_and_templates( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 26},))], ) async def test_min_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a custom min temp.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) min_temp = state.attributes.get("min_temp") @@ -1606,10 +1606,10 @@ async def test_min_temp_custom( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 60},))], ) async def test_max_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a custom max temp.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) max_temp = state.attributes.get("max_temp") @@ -1623,10 +1623,10 @@ async def test_max_temp_custom( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_humidity": 42},))], ) async def test_min_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a custom min humidity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) min_humidity = state.attributes.get("min_humidity") @@ -1640,10 +1640,10 @@ async def test_min_humidity_custom( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_humidity": 58},))], ) async def test_max_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a custom max humidity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) max_humidity = state.attributes.get("max_humidity") @@ -1657,10 +1657,10 @@ async def test_max_humidity_custom( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"temp_step": 0.01},))], ) async def test_temp_step_custom( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a custom temp step.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) temp_step = state.attributes.get("target_temp_step") @@ -1685,10 +1685,10 @@ async def test_temp_step_custom( ], ) async def test_temperature_unit( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that setting temperature unit converts temperature values.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "current_temperature", "77") @@ -1697,21 +1697,21 @@ async def test_temperature_unit( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG, MQTT_CLIMATE_ATTRIBUTES_BLOCKED, @@ -1719,23 +1719,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, climate.DOMAIN, DEFAULT_CONFIG, @@ -1744,13 +1744,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, climate.DOMAIN, DEFAULT_CONFIG, @@ -1759,13 +1759,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, climate.DOMAIN, DEFAULT_CONFIG, @@ -1796,10 +1796,10 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one climate per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, climate.DOMAIN) @pytest.mark.parametrize( @@ -1822,7 +1822,7 @@ async def test_unique_id( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1832,7 +1832,7 @@ async def test_encoding_subscribable_topics( config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, climate.DOMAIN, config, topic, @@ -1844,32 +1844,32 @@ async def test_encoding_subscribable_topics( async def test_discovery_removal_climate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered climate.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, data + hass, mqtt_mock_entry, caplog, climate.DOMAIN, data ) async def test_discovery_update_climate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered climate.""" config1 = {"name": "Beer"} config2 = {"name": "Milk"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, climate.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_climate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered climate.""" @@ -1879,7 +1879,7 @@ async def test_discovery_update_unchanged_climate( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, climate.DOMAIN, data1, @@ -1890,55 +1890,55 @@ async def test_discovery_update_unchanged_climate( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "power_command_topic": "test_topic#" }' data2 = '{ "name": "Milk", "power_command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, climate.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT climate device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT climate device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -1952,7 +1952,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, climate.DOMAIN, config, ["test-topic", "avty-topic"], @@ -1960,16 +1960,16 @@ async def test_entity_id_update_subscriptions( async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, climate.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" config = { @@ -1983,7 +1983,7 @@ async def test_entity_debug_info_message( } await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, climate.DOMAIN, config, climate.SERVICE_TURN_ON, @@ -1995,10 +1995,10 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_precision_default( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that setting precision to tenths works as intended.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -2013,10 +2013,10 @@ async def test_precision_default( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], ) async def test_precision_halves( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that setting precision to halves works as intended.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -2031,10 +2031,10 @@ async def test_precision_halves( [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], ) async def test_precision_whole( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that setting precision to whole works as intended.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -2129,7 +2129,7 @@ async def test_precision_whole( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -2146,7 +2146,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -2224,15 +2224,15 @@ async def test_publishing_with_custom_encoding( ) async def test_humidity_configuration_validity( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, valid: bool, ) -> None: """Test the validity of humidity configurations.""" if valid: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() return with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async def test_reloadable( @@ -2247,21 +2247,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = climate.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = climate.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 154f91974a1..620f1e95c23 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -145,11 +145,11 @@ def help_custom_config( async def help_test_availability_when_connection_lost( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, ) -> None: """Test availability after MQTT disconnection.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.async_block_till_done() state = hass.states.get(f"{domain}.test") @@ -165,13 +165,13 @@ async def help_test_availability_when_connection_lost( async def help_test_availability_without_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test availability without defined availability topic.""" assert "availability_topic" not in config[mqtt.DOMAIN][domain] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() state = hass.states.get(f"{domain}.test") @@ -226,7 +226,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -243,7 +243,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -286,7 +286,7 @@ async def help_test_default_availability_list_payload( async def help_test_default_availability_list_payload_all( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -304,7 +304,7 @@ async def help_test_default_availability_list_payload_all( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -348,7 +348,7 @@ async def help_test_default_availability_list_payload_all( async def help_test_default_availability_list_payload_any( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -366,7 +366,7 @@ async def help_test_default_availability_list_payload_any( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -429,7 +429,7 @@ async def help_test_default_availability_list_single( async def help_test_custom_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -445,7 +445,7 @@ async def help_test_custom_availability_payload( config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" config[mqtt.DOMAIN][domain]["payload_available"] = "good" config[mqtt.DOMAIN][domain]["payload_not_available"] = "nogood" - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -476,7 +476,7 @@ async def help_test_custom_availability_payload( async def help_test_discovery_update_availability( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -484,7 +484,7 @@ async def help_test_discovery_update_availability( This is a test helper for the MQTTAvailability mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add availability settings to config config1 = copy.deepcopy(config) config1[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic1" @@ -554,7 +554,7 @@ async def help_test_discovery_update_availability( async def help_test_setting_attribute_via_mqtt_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -565,7 +565,7 @@ async def help_test_setting_attribute_via_mqtt_json_message( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') state = hass.states.get(f"{domain}.test") @@ -575,7 +575,7 @@ async def help_test_setting_attribute_via_mqtt_json_message( async def help_test_setting_blocked_attribute_via_mqtt_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, extra_blocked_attributes: frozenset[str] | None, @@ -584,7 +584,7 @@ async def help_test_setting_blocked_attribute_via_mqtt_json_message( This is a test helper for the MqttAttributes mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() extra_blocked_attribute_list = list(extra_blocked_attributes or []) # Add JSON attributes settings to config @@ -608,7 +608,7 @@ async def help_test_setting_blocked_attribute_via_mqtt_json_message( async def help_test_setting_attribute_with_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -622,7 +622,7 @@ async def help_test_setting_attribute_with_template( config[mqtt.DOMAIN][domain][ "json_attributes_template" ] = "{{ value_json['Timer1'] | tojson }}" - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) async_fire_mqtt_message( hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}}) @@ -636,7 +636,7 @@ async def help_test_setting_attribute_with_template( async def help_test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -648,7 +648,7 @@ async def help_test_update_with_json_attrs_not_dict( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') state = hass.states.get(f"{domain}.test") @@ -659,7 +659,7 @@ async def help_test_update_with_json_attrs_not_dict( async def help_test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -671,7 +671,7 @@ async def help_test_update_with_json_attrs_bad_json( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry, domain, config) async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") @@ -682,7 +682,7 @@ async def help_test_update_with_json_attrs_bad_json( async def help_test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -691,7 +691,7 @@ async def help_test_discovery_update_attr( This is a test helper for the MqttAttributes mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add JSON attributes settings to config config1 = copy.deepcopy(config) config1[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic1" @@ -723,18 +723,18 @@ async def help_test_discovery_update_attr( async def help_test_unique_id( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, ) -> None: """Test unique id option only creates one entity per unique_id.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() assert len(hass.states.async_entity_ids(domain)) == 1 async def help_test_discovery_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, data: str, @@ -743,7 +743,7 @@ async def help_test_discovery_removal( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() @@ -760,7 +760,7 @@ async def help_test_discovery_removal( async def help_test_discovery_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog, domain, discovery_config1: DiscoveryInfoType, @@ -772,7 +772,7 @@ async def help_test_discovery_update( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add some future configuration to the configurations config1 = copy.deepcopy(discovery_config1) config1["some_future_option_1"] = "future_option_1" @@ -825,7 +825,7 @@ async def help_test_discovery_update( async def help_test_discovery_update_unchanged( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, data1: str, @@ -835,7 +835,7 @@ async def help_test_discovery_update_unchanged( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) await hass.async_block_till_done() @@ -851,14 +851,14 @@ async def help_test_discovery_update_unchanged( async def help_test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, data1: str, data2: str, ) -> None: """Test handling of bad discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def help_test_discovery_broken( async def help_test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, topic: str, @@ -951,7 +951,7 @@ async def help_test_encoding_subscribable_topics( init_payload_value_utf8 = init_payload[1].encode("utf-8") init_payload_value_utf16 = init_payload[1].encode("utf-16") - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, f"homeassistant/{domain}/item1/config", json.dumps(config1) ) @@ -1012,7 +1012,7 @@ async def help_test_encoding_subscribable_topics( async def help_test_entity_device_info_with_identifier( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1020,7 +1020,7 @@ async def help_test_entity_device_info_with_identifier( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1046,7 +1046,7 @@ async def help_test_entity_device_info_with_identifier( async def help_test_entity_device_info_with_connection( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1054,7 +1054,7 @@ async def help_test_entity_device_info_with_connection( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_MAC) @@ -1082,12 +1082,12 @@ async def help_test_entity_device_info_with_connection( async def help_test_entity_device_info_remove( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test device registry remove.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1114,7 +1114,7 @@ async def help_test_entity_device_info_remove( async def help_test_entity_device_info_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1122,7 +1122,7 @@ async def help_test_entity_device_info_update( This is a test helper for the MqttDiscoveryUpdate mixin. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1150,7 +1150,7 @@ async def help_test_entity_device_info_update( async def help_test_entity_id_update_subscriptions( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, topics: list[str] | None = None, @@ -1169,7 +1169,7 @@ async def help_test_entity_id_update_subscriptions( entity_registry = er.async_get(hass) mqtt_mock = await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config, use_discovery=True + hass, mqtt_mock_entry, domain, config, use_discovery=True ) assert mqtt_mock is not None @@ -1196,14 +1196,14 @@ async def help_test_entity_id_update_subscriptions( async def help_test_entity_id_update_discovery_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, topic: str | None = None, ) -> None: """Test MQTT discovery update after entity_id is updated.""" # Add unique_id to config - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["unique_id"] = "TOTALLY_UNIQUE" @@ -1243,7 +1243,7 @@ async def help_test_entity_id_update_discovery_update( async def help_test_entity_debug_info( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1251,7 +1251,7 @@ async def help_test_entity_debug_info( This is a test helper for MQTT debug_info. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1284,7 +1284,7 @@ async def help_test_entity_debug_info( async def help_test_entity_debug_info_max_messages( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1292,7 +1292,7 @@ async def help_test_entity_debug_info_max_messages( This is a test helper for MQTT debug_info. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1342,7 +1342,7 @@ async def help_test_entity_debug_info_max_messages( async def help_test_entity_debug_info_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, service: str, @@ -1357,7 +1357,7 @@ async def help_test_entity_debug_info_message( This is a test helper for MQTT debug_info. """ # Add device settings to config - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) config["unique_id"] = "veryunique" @@ -1454,7 +1454,7 @@ async def help_test_entity_debug_info_message( async def help_test_entity_debug_info_remove( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1462,7 +1462,7 @@ async def help_test_entity_debug_info_remove( This is a test helper for MQTT debug_info. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1504,7 +1504,7 @@ async def help_test_entity_debug_info_remove( async def help_test_entity_debug_info_update_entity_id( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -1512,7 +1512,7 @@ async def help_test_entity_debug_info_update_entity_id( This is a test helper for MQTT debug_info. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1566,12 +1566,12 @@ async def help_test_entity_debug_info_update_entity_id( async def help_test_entity_disabled_by_default( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test device registry remove.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1608,12 +1608,12 @@ async def help_test_entity_disabled_by_default( async def help_test_entity_category( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test device registry remove.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Add device settings to config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1655,7 +1655,7 @@ async def help_test_entity_category( async def help_test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -1700,7 +1700,7 @@ async def help_test_publishing_with_custom_encoding( service_data[test_id].update(parameters) # setup test entities using discovery - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() item: int = 0 for component_config in setup_config: conf = json.dumps(component_config) @@ -1868,7 +1868,7 @@ async def help_test_unload_config_entry(hass: HomeAssistant) -> None: async def help_test_unload_config_entry_with_platform( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: dict[str, dict[str, Any]], ) -> None: @@ -1879,7 +1879,7 @@ async def help_test_unload_config_entry_with_platform( config_name = config_setup with patch("homeassistant.config.load_yaml_config_file", return_value=config_name): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ae7c4089e54..2ebc4a50ef0 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -183,7 +183,7 @@ async def test_user_connection_works( assert result["type"] == "form" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"broker": "127.0.0.1", "advanced_options": False} + result["flow_id"], {"broker": "127.0.0.1"} ) assert result["type"] == "create_entry" @@ -191,7 +191,6 @@ async def test_user_connection_works( "broker": "127.0.0.1", "port": 1883, "discovery": True, - "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -209,7 +208,8 @@ async def test_user_v5_connection_works( mock_try_connection.return_value = True result = await hass.config_entries.flow.async_init( - "mqtt", context={"source": config_entries.SOURCE_USER} + "mqtt", + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, ) assert result["type"] == "form" @@ -231,7 +231,6 @@ async def test_user_v5_connection_works( assert result["result"].data == { "broker": "another-broker", "discovery": True, - "discovery_prefix": "homeassistant", "port": 2345, "protocol": "5", } @@ -283,7 +282,7 @@ async def test_manual_config_set( assert result["type"] == "form" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"broker": "127.0.0.1"} + result["flow_id"], {"broker": "127.0.0.1", "port": "1883"} ) assert result["type"] == "create_entry" @@ -291,7 +290,6 @@ async def test_manual_config_set( "broker": "127.0.0.1", "port": 1883, "discovery": True, - "discovery_prefix": "homeassistant", } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( @@ -346,6 +344,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: }, name="Mosquitto", slug="mosquitto", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -376,6 +375,7 @@ async def test_hassio_confirm( }, name="Mock Addon", slug="mosquitto", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -395,7 +395,6 @@ async def test_hassio_confirm( "username": "mock-user", "password": "mock-pass", "discovery": True, - "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection_success.mock_calls) @@ -425,6 +424,7 @@ async def test_hassio_cannot_connect( }, name="Mock Addon", slug="mosquitto", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -447,14 +447,14 @@ async def test_hassio_cannot_connect( async def test_option_flow( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, ) -> None: """Test config flow options.""" with patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}) ) as yaml_mock: - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { @@ -544,7 +544,7 @@ async def test_option_flow( ) async def test_bad_certificate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection_success: MqttMockPahoClient, mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, @@ -581,7 +581,7 @@ async def test_bad_certificate( # Client key file without client cert, client cert without key file test_input.pop(mqtt.CONF_CLIENT_KEY) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form @@ -640,7 +640,7 @@ async def test_bad_certificate( ) async def test_keepalive_validation( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, mock_reload_after_entry_update: MagicMock, input_value: str, @@ -654,7 +654,7 @@ async def test_keepalive_validation( mqtt.CONF_KEEPALIVE: input_value, } - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form @@ -686,12 +686,12 @@ async def test_keepalive_validation( async def test_disable_birth_will( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { @@ -757,12 +757,12 @@ async def test_disable_birth_will( async def test_invalid_discovery_prefix( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, mock_reload_after_entry_update: MagicMock, ) -> None: """Test setting an invalid discovery prefix.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { @@ -836,12 +836,12 @@ def get_suggested(schema: vol.Schema, key: str) -> Any: async def test_option_flow_default_suggested_values( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection_success: MqttMockPahoClient, mock_reload_after_entry_update: MagicMock, ) -> None: """Test config flow options has default/suggested values.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { mqtt.CONF_BROKER: "test-broker", @@ -991,7 +991,7 @@ async def test_option_flow_default_suggested_values( ) async def test_skipping_advanced_options( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, mock_reload_after_entry_update: MagicMock, advanced_options: bool, @@ -1005,7 +1005,7 @@ async def test_skipping_advanced_options( "advanced_options": advanced_options, } - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Initiate with a basic setup @@ -1016,7 +1016,9 @@ async def test_skipping_advanced_options( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "broker" @@ -1269,7 +1271,9 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) assert result["type"] == "form" assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 3fb008b2181..c388ded6587 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -109,10 +109,10 @@ def cover_platform_only(): ], ) async def test_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -150,10 +150,10 @@ async def test_state_via_state_topic( ], ) async def test_opening_and_closing_state_via_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling opening and closing state via a custom payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -195,10 +195,10 @@ async def test_opening_and_closing_state_via_custom_state_payload( ], ) async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the state after setting the position using optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -247,10 +247,10 @@ async def test_open_closed_state_from_position_optimistic( ], ) async def test_position_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -289,10 +289,10 @@ async def test_position_via_position_topic( ], ) async def test_state_via_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -330,10 +330,10 @@ async def test_state_via_template( ], ) async def test_state_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -369,11 +369,11 @@ async def test_state_via_template_and_entity_id( ) async def test_state_via_template_with_json_value( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic with JSON value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -418,10 +418,10 @@ async def test_state_via_template_with_json_value( ], ) async def test_position_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -508,11 +508,11 @@ async def test_position_via_template_and_entity_id( ) async def test_optimistic_flag( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, assumed_state: bool, ) -> None: """Test assumed_state is set correctly.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -537,10 +537,10 @@ async def test_optimistic_flag( ], ) async def test_optimistic_state_change( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test changing state optimistically.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -599,10 +599,10 @@ async def test_optimistic_state_change( ], ) async def test_optimistic_state_change_with_position( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test changing state optimistically.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -665,10 +665,10 @@ async def test_optimistic_state_change_with_position( ], ) async def test_send_open_cover_command( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of open_cover.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -698,10 +698,10 @@ async def test_send_open_cover_command( ], ) async def test_send_close_cover_command( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of close_cover.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -731,10 +731,10 @@ async def test_send_close_cover_command( ], ) async def test_send_stop_cover_command( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of stop_cover.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -768,10 +768,10 @@ async def test_send_stop_cover_command( ], ) async def test_current_cover_position( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the current cover position.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -823,10 +823,10 @@ async def test_current_cover_position( ], ) async def test_current_cover_position_inverted( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the current cover position.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -886,11 +886,11 @@ async def test_current_cover_position_inverted( async def test_optimistic_position( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic position is not supported.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" in caplog.text @@ -918,10 +918,10 @@ async def test_optimistic_position( ], ) async def test_position_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test cover position update from received MQTT message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -985,12 +985,12 @@ async def test_position_update( ) async def test_set_position_templated( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, pos_call: int, pos_message: str, ) -> None: """Test setting cover position via template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1035,10 +1035,10 @@ async def test_set_position_templated( ], ) async def test_set_position_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting cover position via template and using entities attributes.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1074,10 +1074,10 @@ async def test_set_position_templated_and_attributes( ], ) async def test_set_tilt_templated( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting cover tilt position via template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1119,10 +1119,10 @@ async def test_set_tilt_templated( ], ) async def test_set_tilt_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting cover tilt position via template and using entities attributes.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1200,10 +1200,10 @@ async def test_set_tilt_templated_and_attributes( ], ) async def test_set_position_untemplated( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting cover position via template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1236,10 +1236,10 @@ async def test_set_position_untemplated( ], ) async def test_set_position_untemplated_custom_percentage_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting cover position via template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1270,10 +1270,10 @@ async def test_set_position_untemplated_custom_percentage_range( ], ) async def test_no_command_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test with no command topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("cover.test").attributes["supported_features"] == 240 @@ -1296,10 +1296,10 @@ async def test_no_command_topic( ], ) async def test_no_payload_close( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test with no close payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("cover.test").attributes["supported_features"] == 9 @@ -1322,10 +1322,10 @@ async def test_no_payload_close( ], ) async def test_no_payload_open( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test with no open payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("cover.test").attributes["supported_features"] == 10 @@ -1348,10 +1348,10 @@ async def test_no_payload_open( ], ) async def test_no_payload_stop( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test with no stop payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("cover.test").attributes["supported_features"] == 3 @@ -1376,10 +1376,10 @@ async def test_no_payload_stop( ], ) async def test_with_command_topic_and_tilt( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test with command topic and tilt config.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("cover.test").attributes["supported_features"] == 251 @@ -1405,10 +1405,10 @@ async def test_with_command_topic_and_tilt( ], ) async def test_tilt_defaults( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the defaults.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state_attributes_dict = hass.states.get("cover.test").attributes # Tilt position is not yet known @@ -1436,11 +1436,11 @@ async def test_tilt_defaults( ], ) async def test_tilt_via_invocation_defaults( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt defaults on close/open.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1525,10 +1525,10 @@ async def test_tilt_via_invocation_defaults( ], ) async def test_tilt_given_value( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilting to a given value.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1618,10 +1618,10 @@ async def test_tilt_given_value( ], ) async def test_tilt_given_value_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilting to a given value.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1700,10 +1700,10 @@ async def test_tilt_given_value_optimistic( ], ) async def test_tilt_given_value_altered_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilting to a given value.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -1775,10 +1775,10 @@ async def test_tilt_given_value_altered_range( ], ) async def test_tilt_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt by updating status via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1819,10 +1819,10 @@ async def test_tilt_via_topic( ], ) async def test_tilt_via_topic_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt by updating status via MQTT and template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1864,11 +1864,11 @@ async def test_tilt_via_topic_template( ) async def test_tilt_via_topic_template_json_value( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test tilt by updating status via MQTT and template with JSON value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", '{"Var1": 9, "Var2": 30}') @@ -1914,10 +1914,10 @@ async def test_tilt_via_topic_template_json_value( ], ) async def test_tilt_via_topic_altered_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt status via MQTT with altered tilt range.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1966,10 +1966,10 @@ async def test_tilt_via_topic_altered_range( async def test_tilt_status_out_of_range_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test tilt status via MQTT tilt out of range warning message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "60") @@ -2003,10 +2003,10 @@ async def test_tilt_status_out_of_range_warning( async def test_tilt_status_not_numeric_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test tilt status via MQTT tilt not numeric warning message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "abc") @@ -2036,10 +2036,10 @@ async def test_tilt_status_not_numeric_warning( ], ) async def test_tilt_via_topic_altered_range_inverted( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -2089,10 +2089,10 @@ async def test_tilt_via_topic_altered_range_inverted( ], ) async def test_tilt_via_topic_template_altered_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt status via MQTT and template with altered tilt range.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -2137,10 +2137,10 @@ async def test_tilt_via_topic_template_altered_range( ], ) async def test_tilt_position( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt via method invocation.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -2176,10 +2176,10 @@ async def test_tilt_position( ], ) async def test_tilt_position_templated( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt position via template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -2218,10 +2218,10 @@ async def test_tilt_position_templated( ], ) async def test_tilt_position_altered_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test tilt via method invocation with altered range.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( cover.DOMAIN, @@ -2581,39 +2581,39 @@ async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN + hass, mqtt_mock_entry, cover.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) @@ -2632,10 +2632,10 @@ async def test_custom_availability_payload( ], ) async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of a valid device class.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.attributes.get("device_class") == "garage" @@ -2658,30 +2658,30 @@ async def test_valid_device_class( async def test_invalid_device_class( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid device class.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG, MQTT_COVER_ATTRIBUTES_BLOCKED, @@ -2689,23 +2689,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, cover.DOMAIN, DEFAULT_CONFIG, @@ -2714,13 +2714,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, cover.DOMAIN, DEFAULT_CONFIG, @@ -2729,13 +2729,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, cover.DOMAIN, DEFAULT_CONFIG, @@ -2764,40 +2764,38 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one cover per id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, cover.DOMAIN) async def test_discovery_removal_cover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered cover.""" data = '{ "name": "test", "command_topic": "test_topic" }' - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, cover.DOMAIN, data) async def test_discovery_update_cover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered cover.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, cover.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_cover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered cover.""" @@ -2807,7 +2805,7 @@ async def test_discovery_update_unchanged_cover( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, cover.DOMAIN, data1, @@ -2818,78 +2816,78 @@ async def test_discovery_update_unchanged_cover( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, cover.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT cover device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT cover device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG, SERVICE_OPEN_COVER, @@ -2918,10 +2916,10 @@ async def test_entity_debug_info_message( ], ) async def test_state_and_position_topics_state_not_set_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test state is not set via position topic when both state and position topics are set.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2980,10 +2978,10 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( ], ) async def test_set_state_via_position_using_stopped_state( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via position topic using stopped state.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -3033,10 +3031,10 @@ async def test_set_state_via_position_using_stopped_state( ], ) async def test_position_via_position_topic_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test position by updating status via position template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "99") @@ -3072,11 +3070,11 @@ async def test_position_via_position_topic_template( ) async def test_position_via_position_topic_template_json_value( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test position by updating status via position template with a JSON value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", '{"Var1": 9, "Var2": 60}') @@ -3122,10 +3120,10 @@ async def test_position_via_position_topic_template_json_value( ], ) async def test_position_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test position by updating status via position template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "10") @@ -3160,10 +3158,10 @@ async def test_position_template_with_entity_id( ], ) async def test_position_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test position by updating status via position template and returning json.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3193,10 +3191,10 @@ async def test_position_via_position_topic_template_return_json( async def test_position_via_position_topic_template_return_json_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test position by updating status via position template returning json without position attribute.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3225,10 +3223,10 @@ async def test_position_via_position_topic_template_return_json_warning( ], ) async def test_position_and_tilt_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test position and tilt by updating the position via position template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3279,10 +3277,10 @@ async def test_position_and_tilt_via_position_topic_template_return_json( ], ) async def test_position_via_position_topic_template_all_variables( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test position by updating status via position template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3320,10 +3318,10 @@ async def test_position_via_position_topic_template_all_variables( ], ) async def test_set_state_via_stopped_state_no_position_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via stopped state when no position topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "state-topic", "OPEN") @@ -3371,10 +3369,10 @@ async def test_set_state_via_stopped_state_no_position_topic( async def test_position_via_position_topic_template_return_invalid_json( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test position by updating status via position template and returning invalid json.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3399,11 +3397,11 @@ async def test_position_via_position_topic_template_return_invalid_json( async def test_set_position_topic_without_get_position_topic_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when set_position_topic is used without position_topic.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text @@ -3426,11 +3424,11 @@ async def test_set_position_topic_without_get_position_topic_error( async def test_value_template_without_state_topic_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when value_template is used and state_topic is missing.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text @@ -3453,11 +3451,11 @@ async def test_value_template_without_state_topic_error( async def test_position_template_without_position_topic_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when position_template is used and position_topic is missing.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text @@ -3481,11 +3479,11 @@ async def test_position_template_without_position_topic_error( async def test_set_position_template_without_set_position_topic( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when set_position_template is used and set_position_topic is missing.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text @@ -3509,11 +3507,11 @@ async def test_set_position_template_without_set_position_topic( async def test_tilt_command_template_without_tilt_command_topic( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when tilt_command_template is used and tilt_command_topic is missing.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text @@ -3537,11 +3535,11 @@ async def test_tilt_command_template_without_tilt_command_topic( async def test_tilt_status_template_without_tilt_status_topic_topic( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when tilt_status_template is used and tilt_status_topic is missing.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text @@ -3576,7 +3574,7 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -3591,7 +3589,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -3624,7 +3622,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -3633,7 +3631,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, cover.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][cover.DOMAIN], topic, @@ -3646,21 +3644,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = cover.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = cover.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a0ac73953b4..182a5a0673d 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -41,11 +41,11 @@ def device_tracker_platform_only(): async def test_discover_device_tracker( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test discovering an MQTT device tracker component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -63,11 +63,11 @@ async def test_discover_device_tracker( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -92,11 +92,11 @@ async def test_discovery_broken( async def test_non_duplicate_device_tracker_discovery( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a non duplicate component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -120,11 +120,11 @@ async def test_non_duplicate_device_tracker_discovery( async def test_device_tracker_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of component through empty discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -142,11 +142,11 @@ async def test_device_tracker_removal( async def test_device_tracker_rediscover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test rediscover of removed component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -173,11 +173,11 @@ async def test_device_tracker_rediscover( async def test_duplicate_device_tracker_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a non duplicate component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -198,11 +198,11 @@ async def test_duplicate_device_tracker_removal( async def test_device_tracker_discovery_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a discovery update event.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -231,12 +231,12 @@ async def test_cleanup_device_tracker( hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discovered device is cleaned up when removed from registry.""" assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) async_fire_mqtt_message( @@ -291,11 +291,11 @@ async def test_cleanup_device_tracker( async def test_setting_device_tracker_value_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -319,11 +319,11 @@ async def test_setting_device_tracker_value_via_mqtt_message( async def test_setting_device_tracker_value_via_mqtt_message_and_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -346,11 +346,11 @@ async def test_setting_device_tracker_value_via_mqtt_message_and_template( async def test_setting_device_tracker_value_via_mqtt_message_and_template2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -376,11 +376,11 @@ async def test_setting_device_tracker_value_via_mqtt_message_and_template2( async def test_setting_device_tracker_location_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the location via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -400,11 +400,11 @@ async def test_setting_device_tracker_location_via_mqtt_message( async def test_setting_device_tracker_location_via_lat_lon_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the latitude and longitude via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -461,11 +461,11 @@ async def test_setting_device_tracker_location_via_lat_lon_message( async def test_setting_device_tracker_location_via_reset_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the automatic inference of zones via MQTT via reset.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -537,11 +537,11 @@ async def test_setting_device_tracker_location_via_reset_message( async def test_setting_device_tracker_location_via_abbr_reset_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of reset via abbreviated names and custom payloads via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -580,12 +580,12 @@ async def test_setting_device_tracker_location_via_abbr_reset_message( async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, device_tracker.DOMAIN, DEFAULT_CONFIG, None, @@ -603,10 +603,10 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( ], ) async def test_setup_with_modern_schema( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup using the modern schema.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index feb81592393..bcfd55488bc 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -45,10 +45,10 @@ def binary_sensor_and_sensor_only(): async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get the expected triggers from a discovered mqtt device.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -81,10 +81,10 @@ async def test_get_triggers( async def test_get_unknown_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we don't get unknown triggers.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Discover a sensor (without device triggers) data1 = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -128,10 +128,10 @@ async def test_get_unknown_triggers( async def test_get_non_existing_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test getting non existing triggers.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Discover a sensor (without device triggers) data1 = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -152,10 +152,10 @@ async def test_get_non_existing_triggers( async def test_discover_bad_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test bad discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Test sending bad data data0 = ( '{ "automation_type":"trigger",' @@ -202,10 +202,10 @@ async def test_discover_bad_triggers( async def test_update_remove_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers can be updated and removed.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "automation_type": "trigger", "device": {"identifiers": ["0AFFD2"]}, @@ -272,10 +272,10 @@ async def test_if_fires_on_mqtt_message( hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -351,10 +351,10 @@ async def test_if_fires_on_mqtt_message_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -432,10 +432,10 @@ async def test_if_fires_on_mqtt_message_late_discover( hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing of MQTT device triggers discovered after setup.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -519,10 +519,10 @@ async def test_if_fires_on_mqtt_message_after_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing after update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -598,10 +598,10 @@ async def test_if_fires_on_mqtt_message_after_update( async def test_no_resubscribe_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test subscription to topics without change.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -646,10 +646,10 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -712,13 +712,13 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, calls, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" assert await async_setup_component(hass, "config", {}) assert await async_setup_component(hass, "repairs", {}) await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() ws_client = await hass_ws_client(hass) @@ -783,10 +783,10 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( async def test_attach_remove( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test attach and removal of trigger.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -841,10 +841,10 @@ async def test_attach_remove( async def test_attach_remove_late( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test attach and removal of trigger .""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -907,10 +907,10 @@ async def test_attach_remove_late( async def test_attach_remove_late2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test attach and removal of trigger .""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -969,10 +969,10 @@ async def test_attach_remove_late2( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT device registry integration.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) data = json.dumps( @@ -1007,10 +1007,10 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT device registry integration.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) data = json.dumps( @@ -1043,10 +1043,10 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) config = { @@ -1086,10 +1086,10 @@ async def test_cleanup_trigger( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test trigger discovery topic is cleaned when device is removed from registry.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -1142,10 +1142,10 @@ async def test_cleanup_trigger( async def test_cleanup_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry when trigger is removed.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "automation_type": "trigger", "topic": "test-topic", @@ -1178,10 +1178,10 @@ async def test_cleanup_device( async def test_cleanup_device_several_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry when the last trigger is removed.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1240,13 +1240,13 @@ async def test_cleanup_device_several_triggers( async def test_cleanup_device_with_entity1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry for device with entity. Trigger removed first, then entity. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1301,13 +1301,13 @@ async def test_cleanup_device_with_entity1( async def test_cleanup_device_with_entity2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry for device with entity. Entity removed first, then trigger. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1360,13 +1360,13 @@ async def test_cleanup_device_with_entity2( async def test_trigger_debug_info( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test debug_info. This is a test helper for MQTT debug_info. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) config1 = { diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index cfc6f069b02..81a86f1c61f 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -19,18 +19,6 @@ from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator default_config = { "birth_message": {}, "broker": "mock-broker", - "discovery": True, - "discovery_prefix": "homeassistant", - "keepalive": 60, - "port": 1883, - "protocol": "3.1.1", - "transport": "tcp", - "will_message": { - "payload": "offline", - "qos": 0, - "retain": False, - "topic": "homeassistant/status", - }, } @@ -48,13 +36,14 @@ async def test_entry_diagnostics( hass: HomeAssistant, device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test config entry diagnostics.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [], @@ -171,10 +160,10 @@ async def test_redact_diagnostics( hass: HomeAssistant, device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test redacting diagnostics.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() expected_config = dict(default_config) expected_config["password"] = "**REDACTED**" expected_config["username"] = "**REDACTED**" @@ -265,6 +254,7 @@ async def test_redact_diagnostics( "name_by_user": None, } + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [expected_device], diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index d05c7109845..22cf9ecceed 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -49,10 +49,10 @@ from tests.typing import ( ) async def test_subscribing_config_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test setting up discovery.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] discovery_topic = "homeassistant" @@ -77,13 +77,13 @@ async def test_subscribing_config_topic( ) async def test_invalid_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, topic: str, log: bool, ) -> None: """Test sending to invalid topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -104,11 +104,11 @@ async def test_invalid_topic( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_invalid_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test sending in invalid JSON.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -124,11 +124,11 @@ async def test_invalid_json( async def test_only_valid_components( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a valid component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -150,10 +150,10 @@ async def test_only_valid_components( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_correct_config_discovery( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending in correct JSON.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -171,10 +171,10 @@ async def test_correct_config_discovery( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discovering an MQTT fan.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/fan/bla/config", @@ -192,11 +192,11 @@ async def test_discover_fan( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CLIMATE]) async def test_discover_climate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test discovering an MQTT climate component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "name": "ClimateTest",' ' "current_temperature_topic": "climate/bla/current_temp",' @@ -216,10 +216,10 @@ async def test_discover_climate( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) async def test_discover_alarm_control_panel( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discovering an MQTT alarm control panel component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "name": "AlarmControlPanelTest",' ' "state_topic": "test_topic",' @@ -385,7 +385,7 @@ async def test_discover_alarm_control_panel( ) async def test_discovery_with_object_id( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, config: str, entity_id: str, @@ -393,7 +393,7 @@ async def test_discovery_with_object_id( domain: str, ) -> None: """Test discovering an MQTT entity with object_id.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, topic, config) await hass.async_block_till_done() @@ -407,10 +407,10 @@ async def test_discovery_with_object_id( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_discovery_incl_nodeid( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending in correct JSON with optional node_id included.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/my_node_id/bla/config", @@ -430,11 +430,11 @@ async def test_discovery_incl_nodeid( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_non_duplicate_discovery( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a non duplicate component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -459,10 +459,10 @@ async def test_non_duplicate_discovery( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal of component through empty discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -481,10 +481,10 @@ async def test_removal( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rediscover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test rediscover of removed component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -512,10 +512,10 @@ async def test_rediscover( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test immediate rediscover of removed component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = async_capture_events(hass, EVENT_STATE_CHANGED) async_fire_mqtt_message( @@ -565,10 +565,10 @@ async def test_rapid_rediscover( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover_unique( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test immediate rediscover of removed component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] @callback @@ -628,10 +628,10 @@ async def test_rapid_rediscover_unique( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_reconfigure( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test immediate reconfigure of added component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] @callback @@ -684,11 +684,11 @@ async def test_rapid_reconfigure( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_duplicate_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test for a non duplicate component.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -710,10 +710,10 @@ async def test_cleanup_device( hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discvered device is cleaned up when entry removed from device.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -772,10 +772,10 @@ async def test_cleanup_device_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discvered device is cleaned up when removed through MQTT.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() data = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -819,12 +819,12 @@ async def test_cleanup_device_multiple_config_entries( hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discovered device is cleaned up when entry removed from device.""" assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) config_entry = MockConfigEntry(domain="test", data={}) @@ -924,10 +924,10 @@ async def test_cleanup_device_multiple_config_entries_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test discovered device is cleaned up when removed through MQTT.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -1008,10 +1008,10 @@ async def test_cleanup_device_multiple_config_entries_mqtt( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test expansion of abbreviated discovery payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1071,10 +1071,10 @@ async def test_discovery_expansion( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test expansion of abbreviated discovery payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1117,11 +1117,11 @@ async def test_discovery_expansion_2( @pytest.mark.no_fail_on_log_exception async def test_discovery_expansion_3( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test expansion of broken discovery payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1153,10 +1153,10 @@ async def test_discovery_expansion_3( async def test_discovery_expansion_without_encoding_and_value_template_1( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test expansion of raw availability payload with a template as list.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1205,10 +1205,10 @@ async def test_discovery_expansion_without_encoding_and_value_template_1( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_without_encoding_and_value_template_2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test expansion of raw availability payload with a template directly.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1290,10 +1290,10 @@ ABBREVIATIONS_WHITE_LIST = [ async def test_missing_discover_abbreviations( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Check MQTT platforms for missing abbreviations.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() missing = [] regex = re.compile(r"(CONF_[a-zA-Z\d_]*) *= *[\'\"]([a-zA-Z\d_]*)[\'\"]") for fil in Path(mqtt.__file__).parent.rglob("*.py"): @@ -1319,10 +1319,10 @@ async def test_missing_discover_abbreviations( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_no_implicit_state_topic_switch( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test no implicit state topic for switch.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = '{ "name": "Test1", "command_topic": "cmnd" }' async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) @@ -1352,10 +1352,10 @@ async def test_no_implicit_state_topic_switch( ], ) async def test_complex_discovery_topic_prefix( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Tests handling of discovery topic prefix with multiple slashes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, @@ -1379,10 +1379,10 @@ async def test_complex_discovery_topic_prefix( async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Check MQTT integration discovery subscribe and unsubscribe.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_entity_platform(hass, "config_flow.comp", None) entry = hass.config_entries.async_entries("mqtt")[0] @@ -1426,10 +1426,10 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( async def test_mqtt_discovery_unsubscribe_once( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Check MQTT integration discovery unsubscribe once.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() mock_entity_platform(hass, "config_flow.comp", None) entry = hass.config_entries.async_entries("mqtt")[0] @@ -1464,12 +1464,12 @@ async def test_mqtt_discovery_unsubscribe_once( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_clear_config_topic_disabled_entity( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test the discovery topic is removed when a disabled entity is removed.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # discover an entity that is not enabled by default config = { "name": "sbfspot_12345", @@ -1540,12 +1540,12 @@ async def test_clear_config_topic_disabled_entity( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_clean_up_registry_monitoring( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, device_registry: dr.DeviceRegistry, tmp_path: Path, ) -> None: """Test registry monitoring hook is removed after a reload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() hooks: dict = hass.data["mqtt"].discovery_registry_hooks # discover an entity that is not enabled by default config1 = { @@ -1595,11 +1595,11 @@ async def test_clean_up_registry_monitoring( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_unique_id_collission_has_priority( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, entity_registry: er.EntityRegistry, ) -> None: """Test the unique_id collision detection has priority over registry disabled items.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "name": "sbfspot_12345", "state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/", @@ -1643,10 +1643,10 @@ async def test_unique_id_collission_has_priority( @pytest.mark.xfail(raises=MultipleInvalid) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_update_with_bad_config_not_breaks_discovery( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test a bad update does not break discovery.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # discover a sensor config1 = { "name": "sbfspot_12345", diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 3e3f6219b0d..c274c18bec0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -8,6 +8,7 @@ from voluptuous.error import MultipleInvalid from homeassistant.components import fan, mqtt from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -15,6 +16,8 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.components.mqtt.fan import ( + CONF_DIRECTION_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, CONF_PERCENTAGE_COMMAND_TOPIC, @@ -89,11 +92,11 @@ def fan_platform_only(): async def test_fail_setup_if_no_command_topic( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['fan'][0]['command_topic']" in caplog.text @@ -111,6 +114,8 @@ async def test_fail_setup_if_no_command_topic( "command_topic": "command-topic", "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", @@ -138,11 +143,11 @@ async def test_fail_setup_if_no_command_topic( ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -157,6 +162,14 @@ async def test_controlling_state_via_topic( assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False + async_fire_mqtt_message(hass, "direction-state-topic", "forward") + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + + async_fire_mqtt_message(hass, "direction-state-topic", "reverse") + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + async_fire_mqtt_message(hass, "oscillation-state-topic", "OsC_On") state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is True @@ -262,11 +275,11 @@ async def test_controlling_state_via_topic( ) async def test_controlling_state_via_topic_with_different_speed_range( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic using an alternate speed range.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "percentage-state-topic1", "100") state = hass.states.get("fan.test1") @@ -314,11 +327,11 @@ async def test_controlling_state_via_topic_with_different_speed_range( ) async def test_controlling_state_via_topic_no_percentage_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic without percentage topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -357,6 +370,8 @@ async def test_controlling_state_via_topic_no_percentage_topics( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", @@ -372,6 +387,7 @@ async def test_controlling_state_via_topic_no_percentage_topics( "silent", ], "state_value_template": "{{ value_json.val }}", + "direction_value_template": "{{ value_json.val }}", "oscillation_value_template": "{{ value_json.val }}", "percentage_value_template": "{{ value_json.val }}", "preset_mode_value_template": "{{ value_json.val }}", @@ -384,11 +400,11 @@ async def test_controlling_state_via_topic_no_percentage_topics( ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message (percentage mode).""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -407,6 +423,14 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False + async_fire_mqtt_message(hass, "direction-state-topic", '{"val":"forward"}') + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + + async_fire_mqtt_message(hass, "direction-state-topic", '{"val":"reverse"}') + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + async_fire_mqtt_message(hass, "oscillation-state-topic", '{"val":"oscillate_on"}') state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is True @@ -464,6 +488,8 @@ async def test_controlling_state_via_topic_and_json_message( "name": "test", "state_topic": "shared-state-topic", "command_topic": "command-topic", + "direction_state_topic": "shared-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "shared-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "shared-state-topic", @@ -479,6 +505,7 @@ async def test_controlling_state_via_topic_and_json_message( "silent", ], "state_value_template": "{{ value_json.state }}", + "direction_value_template": "{{ value_json.direction }}", "oscillation_value_template": "{{ value_json.oscillation }}", "percentage_value_template": "{{ value_json.percentage }}", "preset_mode_value_template": "{{ value_json.preset_mode }}", @@ -491,23 +518,31 @@ async def test_controlling_state_via_topic_and_json_message( ) async def test_controlling_state_via_topic_and_json_message_shared_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message using a shared topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN + assert state.attributes.get("direction") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"ON","preset_mode":"eco","oscillation":"oscillate_on","percentage": 50}', + """{ + "state":"ON", + "preset_mode":"eco", + "oscillation":"oscillate_on", + "percentage": 50, + "direction": "forward" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_ON + assert state.attributes.get("direction") == "forward" assert state.attributes.get("oscillating") is True assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 assert state.attributes.get("preset_mode") == "eco" @@ -515,10 +550,17 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"ON","preset_mode":"auto","oscillation":"oscillate_off","percentage": 10}', + """{ + "state":"ON", + "preset_mode":"auto", + "oscillation":"oscillate_off", + "percentage": 10, + "direction": "forward" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_ON + assert state.attributes.get("direction") == "forward" assert state.attributes.get("oscillating") is False assert state.attributes.get(fan.ATTR_PERCENTAGE) == 10 assert state.attributes.get("preset_mode") == "auto" @@ -526,10 +568,17 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"OFF","preset_mode":"auto","oscillation":"oscillate_off","percentage": 0}', + """{ + "state":"OFF", + "preset_mode":"auto", + "oscillation":"oscillate_off", + "percentage": 0, + "direction": "reverse" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_OFF + assert state.attributes.get("direction") == "reverse" assert state.attributes.get("oscillating") is False assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get("preset_mode") == "auto" @@ -555,6 +604,7 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "command_topic": "command-topic", "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", + "direction_command_topic": "direction-command-topic", "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", @@ -572,10 +622,10 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( ) async def test_sending_mqtt_commands_and_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic mode without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -599,6 +649,24 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "forward", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "reverse", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_oscillate(hass, "fan.test", True) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "OsC_On", 0, False @@ -711,10 +779,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_mqtt_commands_with_alternate_speed_range( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic using an alternate speed range.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_set_percentage(hass, "fan.test1", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -803,11 +871,11 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( ) async def test_sending_mqtt_commands_and_optimistic_no_legacy( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test optimistic mode without state topic without legacy speed command topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -924,6 +992,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( "name": "test", "command_topic": "command-topic", "command_template": "state: {{ value }}", + "direction_command_topic": "direction-command-topic", + "direction_command_template": "direction: {{ value }}", "oscillation_command_topic": "oscillation-command-topic", "oscillation_command_template": "oscillation: {{ value }}", "percentage_command_topic": "percentage-command-topic", @@ -942,10 +1012,10 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( ) async def test_sending_mqtt_command_templates_( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic mode without state topic without legacy speed command topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -969,6 +1039,24 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "direction: forward", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "direction: reverse", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + assert state.attributes.get(ATTR_ASSUMED_STATE) + with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", -1) @@ -1082,10 +1170,10 @@ async def test_sending_mqtt_command_templates_( ) async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic mode without state topic without percentage command topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1131,6 +1219,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", @@ -1150,10 +1240,10 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( ) async def test_sending_mqtt_commands_and_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic mode with state topic and turn on attributes.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1250,6 +1340,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "forward", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_oscillate(hass, "fan.test", True) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "oscillate_on", 0, False @@ -1275,6 +1374,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "reverse", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_oscillate(hass, "fan.test", False) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "oscillate_off", 0, False @@ -1368,11 +1476,17 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ATTR_OSCILLATING, True, ), + ( + CONF_DIRECTION_STATE_TOPIC, + "reverse", + ATTR_DIRECTION, + "reverse", + ), ], ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1383,10 +1497,11 @@ async def test_encoding_subscribable_topics( config[ATTR_PRESET_MODES] = ["eco", "auto"] config[CONF_PRESET_MODE_COMMAND_TOPIC] = "fan/some_preset_mode_command_topic" config[CONF_PERCENTAGE_COMMAND_TOPIC] = "fan/some_percentage_command_topic" + config[CONF_DIRECTION_COMMAND_TOPIC] = "fan/some_direction_command_topic" config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, fan.DOMAIN, config, topic, @@ -1404,6 +1519,7 @@ async def test_encoding_subscribable_topics( fan.DOMAIN: { "name": "test", "command_topic": "command-topic", + "direction_command_topic": "direction-command-topic", "oscillation_command_topic": "oscillation-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "percentage_command_topic": "percentage-command-topic", @@ -1418,11 +1534,11 @@ async def test_encoding_subscribable_topics( ) async def test_attributes( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1432,18 +1548,28 @@ async def test_attributes( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is None + assert state.attributes.get(fan.ATTR_DIRECTION) is None await common.async_turn_off(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is None + assert state.attributes.get(fan.ATTR_DIRECTION) is None await common.async_oscillate(hass, "fan.test", True) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is True + assert state.attributes.get(fan.ATTR_DIRECTION) is None + + await common.async_set_direction(hass, "fan.test", "reverse") + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(fan.ATTR_OSCILLATING) is True + assert state.attributes.get(fan.ATTR_DIRECTION) == "reverse" await common.async_oscillate(hass, "fan.test", False) state = hass.states.get("fan.test") @@ -1451,6 +1577,13 @@ async def test_attributes( assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is False + await common.async_set_direction(hass, "fan.test", "forward") + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(fan.ATTR_OSCILLATING) is False + assert state.attributes.get(fan.ATTR_DIRECTION) == "forward" + @pytest.mark.parametrize( ("name", "hass_config", "success", "features"), @@ -1694,53 +1827,65 @@ async def test_attributes( True, fan.FanEntityFeature.PRESET_MODE, ), + ( + "test17", + { + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test17", + "command_topic": "command-topic", + "direction_command_topic": "direction-command-topic", + } + } + }, + True, + fan.FanEntityFeature.DIRECTION, + ), ], ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, name: str, success: bool, features, ) -> None: """Test optimistic mode without state topic.""" if success: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(f"fan.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features return with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" - await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN - ) + await help_test_availability_when_connection_lost(hass, mqtt_mock_entry, fan.DOMAIN) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG, True, @@ -1750,12 +1895,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG, True, @@ -1765,21 +1910,21 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG, MQTT_FAN_ATTRIBUTES_BLOCKED, @@ -1787,23 +1932,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, fan.DOMAIN, DEFAULT_CONFIG, @@ -1812,13 +1957,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, fan.DOMAIN, DEFAULT_CONFIG, @@ -1827,12 +1972,12 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, fan.DOMAIN, DEFAULT_CONFIG ) @@ -1860,40 +2005,38 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, fan.DOMAIN) async def test_discovery_removal_fan( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered fan.""" data = '{ "name": "test", "command_topic": "test_topic" }' - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, fan.DOMAIN, data) async def test_discovery_update_fan( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered fan.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, fan.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_fan( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered fan.""" @@ -1903,7 +2046,7 @@ async def test_discovery_update_unchanged_fan( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, fan.DOMAIN, data1, @@ -1914,7 +2057,7 @@ async def test_discovery_update_unchanged_fan( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -1922,71 +2065,71 @@ async def test_discovery_broken( data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, fan.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, fan.DOMAIN, DEFAULT_CONFIG, fan.SERVICE_TURN_ON, @@ -2031,11 +2174,18 @@ async def test_entity_debug_info_message( "oscillate_on", "oscillation_command_template", ), + ( + fan.SERVICE_SET_DIRECTION, + "direction_command_topic", + {fan.ATTR_DIRECTION: "forward"}, + "forward", + "direction_command_template", + ), ], ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -2051,7 +2201,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -2075,21 +2225,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = fan.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = fan.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 0517e8e6a9c..90b2e6d5ba6 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -133,12 +133,12 @@ async def async_set_humidity( ) async def test_fail_setup_if_no_command_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['humidifier'][0]['command_topic']. Got None" in caplog.text @@ -177,11 +177,11 @@ async def test_fail_setup_if_no_command_topic( ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -280,12 +280,12 @@ async def test_controlling_state_via_topic( ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -372,11 +372,11 @@ async def test_controlling_state_via_topic_and_json_message( ) async def test_controlling_state_via_topic_and_json_message_shared_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message using a shared topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -447,11 +447,11 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( ) async def test_sending_mqtt_commands_and_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test optimistic mode without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -547,11 +547,11 @@ async def test_sending_mqtt_commands_and_optimistic( ) async def test_sending_mqtt_command_templates_( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Testing command templates with optimistic mode without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -648,11 +648,11 @@ async def test_sending_mqtt_command_templates_( ) async def test_sending_mqtt_commands_and_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test optimistic mode with state topic and turn on attributes.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -753,7 +753,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -765,7 +765,7 @@ async def test_encoding_subscribable_topics( config[CONF_MODE_COMMAND_TOPIC] = "humidifier/some_mode_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, humidifier.DOMAIN, config, topic, @@ -796,11 +796,11 @@ async def test_encoding_subscribable_topics( ) async def test_attributes( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -867,6 +867,19 @@ async def test_attributes( }, True, ), + ( + { + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_4", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": None, + } + } + }, + True, + ), ( { mqtt.DOMAIN: { @@ -939,15 +952,15 @@ async def test_attributes( ) async def test_validity_configurations( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, valid: bool, ) -> None: """Test validity of configurations.""" if valid: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() return with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize( @@ -1043,49 +1056,49 @@ async def test_validity_configurations( ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, name: str, success: bool, features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" if success: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get(f"humidifier.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features return with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN + hass, mqtt_mock_entry, humidifier.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG, True, @@ -1095,12 +1108,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG, True, @@ -1110,21 +1123,21 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG, MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED, @@ -1132,23 +1145,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, @@ -1157,13 +1170,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, @@ -1172,13 +1185,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, @@ -1211,27 +1224,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, humidifier.DOMAIN) async def test_discovery_removal_humidifier( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered humidifier.""" data = '{ "name": "test", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, data + hass, mqtt_mock_entry, caplog, humidifier.DOMAIN, data ) async def test_discovery_update_humidifier( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered humidifier.""" @@ -1247,7 +1260,7 @@ async def test_discovery_update_humidifier( } await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, humidifier.DOMAIN, config1, @@ -1257,7 +1270,7 @@ async def test_discovery_update_humidifier( async def test_discovery_update_unchanged_humidifier( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered humidifier.""" @@ -1267,7 +1280,7 @@ async def test_discovery_update_unchanged_humidifier( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, humidifier.DOMAIN, data1, @@ -1278,78 +1291,78 @@ async def test_discovery_update_unchanged_humidifier( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, humidifier.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, humidifier.DOMAIN, DEFAULT_CONFIG, humidifier.SERVICE_TURN_ON, @@ -1391,7 +1404,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -1407,7 +1420,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -1431,21 +1444,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = humidifier.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_config_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index cdc31429e2f..498365de4a3 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -32,7 +32,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -118,20 +117,20 @@ def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: async def test_mqtt_connects_on_home_assistant_mqtt_setup( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test if client is connected after mqtt init on bootstrap.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert mqtt_client_mock.connect.call_count == 1 async def test_mqtt_disconnects_on_home_assistant_stop( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test if client stops on HA stop.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() hass.bus.fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() await hass.async_block_till_done() @@ -182,10 +181,10 @@ async def test_mqtt_await_ack_at_disconnect( async def test_publish( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the publish function.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await mqtt.async_publish(hass, "test-topic", "test-payload") await hass.async_block_till_done() assert mqtt_mock.async_publish.called @@ -296,7 +295,7 @@ async def test_command_template_value(hass: HomeAssistant) -> None: ) async def test_command_template_variables( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, config: ConfigType, ) -> None: """Test the rendering of entity variables.""" @@ -305,7 +304,7 @@ async def test_command_template_variables( fake_state = ha.State("select.test_select", "milk") mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.async_block_till_done() async_fire_mqtt_message(hass, "homeassistant/select/bla/config", json.dumps(config)) await hass.async_block_till_done() @@ -398,10 +397,10 @@ async def test_value_template_value(hass: HomeAssistant) -> None: async def test_service_call_without_topic_does_not_publish( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call if topic is missing.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() with pytest.raises(vol.Invalid): await hass.services.async_call( mqtt.DOMAIN, @@ -413,13 +412,13 @@ async def test_service_call_without_topic_does_not_publish( async def test_service_call_with_topic_and_topic_template_does_not_publish( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with topic/topic template. If both 'topic' and 'topic_template' are provided then fail. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() topic = "test/topic" topic_template = "test/{{ 'topic' }}" with pytest.raises(vol.Invalid): @@ -437,10 +436,10 @@ async def test_service_call_with_topic_and_topic_template_does_not_publish( async def test_service_call_with_invalid_topic_template_does_not_publish( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with a problematic topic template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -454,13 +453,13 @@ async def test_service_call_with_invalid_topic_template_does_not_publish( async def test_service_call_with_template_topic_renders_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with rendered topic template. If 'topic_template' is provided and 'topic' is not, then render it. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -475,13 +474,13 @@ async def test_service_call_with_template_topic_renders_template( async def test_service_call_with_template_topic_renders_invalid_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with rendered, invalid topic template. If a wildcard topic is rendered, then fail. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -495,13 +494,13 @@ async def test_service_call_with_template_topic_renders_invalid_topic( async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with unrendered template. If both 'payload' and 'payload_template' are provided then fail. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() payload = "not a template" payload_template = "a template" with pytest.raises(vol.Invalid): @@ -519,13 +518,13 @@ async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_t async def test_service_call_with_template_payload_renders_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with rendered template. If 'payload_template' is provided and 'payload' is not, then render it. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -551,10 +550,10 @@ async def test_service_call_with_template_payload_renders_template( async def test_service_call_with_bad_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with a bad template does not publish.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -565,13 +564,13 @@ async def test_service_call_with_bad_template( async def test_service_call_with_payload_doesnt_render_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with unrendered template. If both 'payload' and 'payload_template' are provided then fail. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() payload = "not a template" payload_template = "a template" with pytest.raises(vol.Invalid): @@ -589,13 +588,13 @@ async def test_service_call_with_payload_doesnt_render_template( async def test_service_call_with_ascii_qos_retain_flags( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the service call with args that can be misinterpreted. Empty payload message and ascii formatted qos and retain flags. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -615,10 +614,10 @@ async def test_service_call_with_ascii_qos_retain_flags( async def test_publish_function_with_bad_encoding_conditions( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test internal publish function with basic use cases.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_publish( hass, "some-topic", "test-payload", qos=0, retain=False, encoding=None ) @@ -818,11 +817,11 @@ def test_entity_device_info_schema() -> None: async def test_handle_logging_on_writing_the_entity_state( hass: HomeAssistant, mock_hass_config: None, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test on log handling when an error occurs writing the state.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() async_fire_mqtt_message(hass, "test/state", b"initial_state") await hass.async_block_till_done() @@ -845,12 +844,12 @@ async def test_handle_logging_on_writing_the_entity_state( async def test_receiving_non_utf8_message_gets_logged( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, caplog: pytest.LogCaptureFixture, ) -> None: """Test receiving a non utf8 encoded message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "test-topic", b"\x9a") @@ -863,12 +862,12 @@ async def test_receiving_non_utf8_message_gets_logged( async def test_all_subscriptions_run_when_decode_fails( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test all other subscriptions still run when decode fails for one.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic", record_calls, encoding="ascii") await mqtt.async_subscribe(hass, "test-topic", record_calls) @@ -880,12 +879,12 @@ async def test_all_subscriptions_run_when_decode_fails( async def test_subscribe_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of a topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "test-topic", "test-payload") @@ -909,12 +908,12 @@ async def test_subscribe_topic( async def test_subscribe_topic_non_async( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of a topic using the non-async function.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() unsub = await hass.async_add_executor_job( mqtt.subscribe, hass, "test-topic", record_calls ) @@ -937,61 +936,23 @@ async def test_subscribe_topic_non_async( async def test_subscribe_bad_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test the subscription of a topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() with pytest.raises(HomeAssistantError): await mqtt.async_subscribe(hass, 55, record_calls) # type: ignore[arg-type] -# Support for a deprecated callback type was removed with HA core 2023.3.0 -# Test can be removed from HA core 2023.5.0 -async def test_subscribe_with_deprecated_callback_fails( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the subscription of a topic using deprecated callback signature fails.""" - - async def record_calls(topic: str, payload: ReceivePayloadType, qos: int) -> None: - """Record calls.""" - - with pytest.raises(HomeAssistantError): - await mqtt.async_subscribe(hass, "test-topic", record_calls) - # Test with partial wrapper - with pytest.raises(HomeAssistantError): - await mqtt.async_subscribe(hass, "test-topic", RecordCallsPartial(record_calls)) - - -# Support for a deprecated callback type was removed with HA core 2023.3.0 -# Test can be removed from HA core 2023.5.0 -async def test_subscribe_deprecated_async_fails( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the subscription of a topic using deprecated coroutine signature fails.""" - - @callback - def async_record_calls(topic: str, payload: ReceivePayloadType, qos: int) -> None: - """Record calls.""" - - with pytest.raises(HomeAssistantError): - await mqtt.async_subscribe(hass, "test-topic", async_record_calls) - - # Test with partial wrapper - with pytest.raises(HomeAssistantError): - await mqtt.async_subscribe( - hass, "test-topic", RecordCallsPartial(async_record_calls) - ) - - async def test_subscribe_topic_not_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test if subscribed topic is not a match.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "another-test-topic", "test-payload") @@ -1002,12 +963,12 @@ async def test_subscribe_topic_not_match( async def test_subscribe_topic_level_wildcard( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/+/on", record_calls) async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") @@ -1020,12 +981,12 @@ async def test_subscribe_topic_level_wildcard( async def test_subscribe_topic_level_wildcard_no_subtree_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/+/on", record_calls) async_fire_mqtt_message(hass, "test-topic/bier", "test-payload") @@ -1036,12 +997,12 @@ async def test_subscribe_topic_level_wildcard_no_subtree_match( async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic-123", "test-payload") @@ -1052,12 +1013,12 @@ async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( async def test_subscribe_topic_subtree_wildcard_subtree_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") @@ -1070,12 +1031,12 @@ async def test_subscribe_topic_subtree_wildcard_subtree_topic( async def test_subscribe_topic_subtree_wildcard_root_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic", "test-payload") @@ -1088,12 +1049,12 @@ async def test_subscribe_topic_subtree_wildcard_root_topic( async def test_subscribe_topic_subtree_wildcard_no_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "another-test-topic", "test-payload") @@ -1104,12 +1065,12 @@ async def test_subscribe_topic_subtree_wildcard_no_match( async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/test-topic", "test-payload") @@ -1122,12 +1083,12 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/test-topic/here-iam", "test-payload") @@ -1140,12 +1101,12 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/here-iam/test-topic", "test-payload") @@ -1156,12 +1117,12 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/another-test-topic", "test-payload") @@ -1172,12 +1133,12 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( async def test_subscribe_topic_sys_root( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "$test-topic/subtree/on", record_calls) async_fire_mqtt_message(hass, "$test-topic/subtree/on", "test-payload") @@ -1190,12 +1151,12 @@ async def test_subscribe_topic_sys_root( async def test_subscribe_topic_sys_root_and_wildcard_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root and wildcard topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "$test-topic/#", record_calls) async_fire_mqtt_message(hass, "$test-topic/some-topic", "test-payload") @@ -1208,12 +1169,12 @@ async def test_subscribe_topic_sys_root_and_wildcard_topic( async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root and wildcard subtree topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "$test-topic/subtree/#", record_calls) async_fire_mqtt_message(hass, "$test-topic/subtree/some-topic", "test-payload") @@ -1226,12 +1187,12 @@ async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( async def test_subscribe_special_characters( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription to topics with special characters.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() topic = "/test-topic/$(.)[^]{-}" payload = "p4y.l[]a|> ?" @@ -1250,14 +1211,14 @@ async def test_subscribe_special_characters( async def test_subscribe_same_topic( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test subscribing to same topic twice and simulate retained messages. When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again for it to resend any retained messages. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1303,11 +1264,11 @@ async def test_subscribe_same_topic( async def test_not_calling_unsubscribe_with_active_subscribers( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test not calling unsubscribe() when other subscribers are active.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1326,7 +1287,7 @@ async def test_not_calling_unsubscribe_with_active_subscribers( async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test not calling subscribe() when it is unsubscribed. @@ -1334,7 +1295,7 @@ async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( Make sure subscriptions are cleared if unsubscribed before the subscribe cool down period has ended. """ - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1350,10 +1311,10 @@ async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( async def test_unsubscribe_race( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test not calling unsubscribe() when other subscribers are active.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1409,11 +1370,11 @@ async def test_unsubscribe_race( async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test subscriptions are restored on reconnect.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1440,11 +1401,11 @@ async def test_restore_subscriptions_on_reconnect( async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test active subscriptions are restored correctly on reconnect.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1490,11 +1451,11 @@ async def test_restore_all_active_subscriptions_on_reconnect( async def test_subscribed_at_highest_qos( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test the highest qos as assigned when subscribing to the same topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1590,11 +1551,11 @@ async def test_canceling_debouncer_on_shutdown( hass: HomeAssistant, record_calls: MessageCallbackType, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test canceling the debouncer when HA shuts down.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True @@ -1690,11 +1651,11 @@ async def test_initial_setup_logs_error( async def test_logs_error_if_no_connect_broker( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for setup failure if connection to broker is missing.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # test with rc = 3 -> broker unavailable mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 3) await hass.async_block_till_done() @@ -1708,11 +1669,11 @@ async def test_logs_error_if_no_connect_broker( async def test_handle_mqtt_on_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test receiving an ACK callback before waiting for it.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # Simulate an ACK for mid == 1, this will call mqtt_mock._mqtt_handle_mid(mid) mqtt_client_mock.on_publish(mqtt_client_mock, None, 1) await hass.async_block_till_done() @@ -1748,13 +1709,13 @@ async def test_publish_error( @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) async def test_subscribe_error( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, caplog: pytest.LogCaptureFixture, ) -> None: """Test publish error.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await hass.async_block_till_done() await hass.async_block_till_done() @@ -1773,7 +1734,7 @@ async def test_subscribe_error( async def test_handle_message_callback( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for handling an incoming message callback.""" @@ -1783,7 +1744,7 @@ async def test_handle_message_callback( def _callback(args) -> None: callbacks.append(args) - mock_mqtt = await mqtt_mock_entry_no_yaml_config() + mock_mqtt = await mqtt_mock_entry() msg = ReceiveMessage("some-topic", b"test-payload", 1, False) mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await mqtt.async_subscribe(hass, "some-topic", _callback) @@ -1870,12 +1831,12 @@ async def test_setup_manual_mqtt_empty_platform( ) async def test_setup_mqtt_client_protocol( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, protocol: int, ) -> None: """Test MQTT client protocol setup.""" with patch("paho.mqtt.client.Client") as mock_client: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # check if protocol setup was correctly assert mock_client.call_args[1]["protocol"] == protocol @@ -1957,7 +1918,7 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, insecure_param: bool | str, ) -> None: """Test setup uses bundled certs when certificate is set to auto and insecure.""" @@ -1975,7 +1936,7 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( with patch("paho.mqtt.client.Client") as mock_client: mock_client().tls_set = mock_tls_set mock_client().tls_insecure_set = mock_tls_insecure_set - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() assert calls @@ -2001,10 +1962,10 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( async def test_tls_version( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test setup defaults for tls.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() assert ( mqtt_client_mock.tls_set.mock_calls[0][2]["tls_version"] @@ -2029,10 +1990,10 @@ async def test_tls_version( async def test_custom_birth_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending birth message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() birth = asyncio.Event() async def wait_birth(msg: ReceiveMessage) -> None: @@ -2064,10 +2025,10 @@ async def test_custom_birth_message( async def test_default_birth_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending birth message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() birth = asyncio.Event() async def wait_birth(msg: ReceiveMessage) -> None: @@ -2091,16 +2052,29 @@ async def test_default_birth_message( async def test_no_birth_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test disabling birth message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() await asyncio.sleep(0.2) mqtt_client_mock.publish.assert_not_called() + async def callback(msg: ReceiveMessage) -> None: + """Handle birth message.""" + + # Assert the subscribe debouncer subscribes after + # about SUBSCRIBE_COOLDOWN (0.1) sec + # but sooner than INITIAL_SUBSCRIBE_COOLDOWN (1.0) + + mqtt_client_mock.reset_mock() + await mqtt.async_subscribe(hass, "homeassistant/some-topic", callback) + await hass.async_block_till_done() + await asyncio.sleep(0.2) + mqtt_client_mock.subscribe.assert_called() + @pytest.mark.parametrize( "mqtt_config_entry_data", @@ -2120,10 +2094,10 @@ async def test_delayed_birth_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending birth message does not happen until Home Assistant starts.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() hass.state = CoreState.starting birth = asyncio.Event() @@ -2182,10 +2156,10 @@ async def test_delayed_birth_message( async def test_custom_will_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test will message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() mqtt_client_mock.will_set.assert_called_with( topic="death", payload="death", qos=0, retain=False @@ -2195,10 +2169,10 @@ async def test_custom_will_message( async def test_default_will_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test will message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() mqtt_client_mock.will_set.assert_called_with( topic="homeassistant/status", payload="offline", qos=0, retain=False @@ -2212,10 +2186,10 @@ async def test_default_will_message( async def test_no_will_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test will message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() mqtt_client_mock.will_set.assert_not_called() @@ -2233,11 +2207,11 @@ async def test_no_will_message( async def test_mqtt_subscribes_topics_on_connect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, ) -> None: """Test subscription to topic on connect.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await mqtt.async_subscribe(hass, "topic/test", record_calls) await mqtt.async_subscribe(hass, "home/sensor", record_calls, 2) @@ -2256,34 +2230,29 @@ async def test_mqtt_subscribes_topics_on_connect( mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) -async def test_update_incomplete_entry( +async def test_default_entry_setting_are_applied( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, ) -> None: - """Test if the MQTT component loads when config entry data is incomplete.""" + """Test if the MQTT component loads when config entry data not has all default settings.""" data = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' ' "unique_id": "unique" }' ) - # Config entry data is incomplete + # Config entry data is incomplete but valid according the schema entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] entry.data = {"broker": "test-broker", "port": 1234} - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() await hass.async_block_till_done() - # Config entry data should now be updated - assert dict(entry.data) == { - "broker": "test-broker", - "port": 1234, - "discovery_prefix": "homeassistant", - } - # Discover a device to verify the entry was setup correctly + # The discovery prefix should be the default + # And that the default settings were merged async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() @@ -2291,28 +2260,14 @@ async def test_update_incomplete_entry( assert device_entry is not None -async def test_fail_no_broker( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mqtt_client_mock: MqttMockPahoClient, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test if the MQTT component loads when broker configuration is missing.""" - # Config entry data is incomplete - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={}) - entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry.entry_id) - assert "MQTT broker is not configured, please configure it" in caplog.text - - @pytest.mark.no_fail_on_log_exception async def test_message_callback_exception_gets_logged( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test exception raised by message handler.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @callback def bad_handler(*args) -> None: @@ -2332,10 +2287,10 @@ async def test_message_callback_exception_gets_logged( async def test_mqtt_ws_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT websocket subscription.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "mqtt/subscribe", "topic": "test-topic"}) response = await client.receive_json() @@ -2397,11 +2352,11 @@ async def test_mqtt_ws_subscription( async def test_mqtt_ws_subscription_not_admin( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, hass_read_only_access_token: str, ) -> None: """Test MQTT websocket user is not admin.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() client = await hass_ws_client(hass, access_token=hass_read_only_access_token) await client.send_json({"id": 5, "type": "mqtt/subscribe", "topic": "test-topic"}) response = await client.receive_json() @@ -2411,10 +2366,10 @@ async def test_mqtt_ws_subscription_not_admin( async def test_dump_service( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that we can dump a topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() mopen = mock_open() await hass.services.async_call( @@ -2437,12 +2392,12 @@ async def test_mqtt_ws_remove_discovered_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, hass_ws_client: WebSocketGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT websocket device removal.""" assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() data = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -2479,10 +2434,10 @@ async def test_mqtt_ws_get_device_debug_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, hass_ws_client: WebSocketGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT websocket device debug info.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config_sensor = { "device": {"identifiers": ["0AFFD2"]}, "state_topic": "foobar/sensor", @@ -2545,10 +2500,10 @@ async def test_mqtt_ws_get_device_debug_info_binary( hass: HomeAssistant, device_registry: dr.DeviceRegistry, hass_ws_client: WebSocketGenerator, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test MQTT websocket device debug info.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "device": {"identifiers": ["0AFFD2"]}, "topic": "foobar/image", @@ -2609,10 +2564,10 @@ async def test_mqtt_ws_get_device_debug_info_binary( async def test_debug_info_multiple_devices( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test we get correct debug_info when multiple devices are present.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() devices: list[_DebugInfo] = [ { "domain": "sensor", @@ -2691,10 +2646,10 @@ async def test_debug_info_multiple_devices( async def test_debug_info_multiple_entities_triggers( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test we get correct debug_info for a device with multiple entities and triggers.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config: list[_DebugInfo] = [ { "domain": "sensor", @@ -2780,10 +2735,10 @@ async def test_debug_info_non_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test we get empty debug_info for a device with non MQTT entities.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() domain = "sensor" platform = getattr(hass.components, f"test.{domain}") platform.init() @@ -2812,10 +2767,10 @@ async def test_debug_info_non_mqtt( async def test_debug_info_wildcard( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test debug info.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "device": {"identifiers": ["helloworld"]}, "name": "test", @@ -2860,10 +2815,10 @@ async def test_debug_info_wildcard( async def test_debug_info_filter_same( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test debug info removes messages with same timestamp.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "device": {"identifiers": ["helloworld"]}, "name": "test", @@ -2920,10 +2875,10 @@ async def test_debug_info_filter_same( async def test_debug_info_same_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test debug info.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "device": {"identifiers": ["helloworld"]}, "name": "test", @@ -2974,10 +2929,10 @@ async def test_debug_info_same_topic( async def test_debug_info_qos_retain( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test debug info.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "device": {"identifiers": ["helloworld"]}, "name": "test", @@ -3033,10 +2988,10 @@ async def test_debug_info_qos_retain( async def test_publish_json_from_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the publishing of call to services.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() test_str = "{'valid': 'python', 'invalid': 'json'}" test_str_tpl = "{'valid': '{{ \"python\" }}', 'invalid': 'json'}" @@ -3085,21 +3040,31 @@ async def test_publish_json_from_template( async def test_subscribe_connection_status( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test connextion status subscription.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() - mqtt_connected_calls = [] + mqtt_mock = await mqtt_mock_entry() + mqtt_connected_calls_callback: list[bool] = [] + mqtt_connected_calls_async: list[bool] = [] @callback - def async_mqtt_connected(status: bool) -> None: + def async_mqtt_connected_callback(status: bool) -> None: """Update state on connection/disconnection to MQTT broker.""" - mqtt_connected_calls.append(status) + mqtt_connected_calls_callback.append(status) + + async def async_mqtt_connected_async(status: bool) -> None: + """Update state on connection/disconnection to MQTT broker.""" + mqtt_connected_calls_async.append(status) mqtt_mock.connected = True - unsub = mqtt.async_subscribe_connection_status(hass, async_mqtt_connected) + unsub_callback = mqtt.async_subscribe_connection_status( + hass, async_mqtt_connected_callback + ) + unsub_async = mqtt.async_subscribe_connection_status( + hass, async_mqtt_connected_async + ) await hass.async_block_till_done() # Mock connection status @@ -3112,22 +3077,27 @@ async def test_subscribe_connection_status( await hass.async_block_till_done() # Unsubscribe - unsub() + unsub_callback() + unsub_async() mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() # Check calls - assert len(mqtt_connected_calls) == 2 - assert mqtt_connected_calls[0] is True - assert mqtt_connected_calls[1] is False + assert len(mqtt_connected_calls_callback) == 2 + assert mqtt_connected_calls_callback[0] is True + assert mqtt_connected_calls_callback[1] is False + + assert len(mqtt_connected_calls_async) == 2 + assert mqtt_connected_calls_async[0] is True + 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_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test a deprecation warning is is logged once per platform.""" @@ -3204,11 +3174,11 @@ async def test_publish_or_subscribe_without_valid_config_entry( ) async def test_disabling_and_enabling_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test disabling and enabling the config entry.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert entry.state is ConfigEntryState.LOADED # Late discovery of a light @@ -3286,12 +3256,12 @@ async def test_disabling_and_enabling_entry( ) async def test_setup_manual_items_with_unique_ids( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, unique: bool, ) -> None: """Test setup manual items is generating unique id's.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3312,46 +3282,16 @@ async def test_setup_manual_items_with_unique_ids( assert bool("Platform mqtt does not generate unique IDs." in caplog.text) != unique -async def test_remove_unknown_conf_entry_options( - hass: HomeAssistant, - mqtt_client_mock: MqttMockPahoClient, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test unknown keys in config entry data is removed.""" - mqtt_config_entry_data = { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - "old_option": "old_value", - } - - entry = MockConfigEntry( - data=mqtt_config_entry_data, - domain=mqtt.DOMAIN, - title="MQTT", - ) - - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert mqtt.client.CONF_PROTOCOL not in entry.data - assert ( - "The following unsupported configuration options were removed from the " - "MQTT config entry: {'old_option'}" - ) in caplog.text - - -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) @pytest.mark.parametrize( "hass_config", [ { "mqtt": { - "light": [ + "sensor": [ { "name": "test_manual", "unique_id": "test_manual_unique_id123", - "command_topic": "test-topic_manual", + "state_topic": "test-topic_manual", } ] } @@ -3360,26 +3300,27 @@ async def test_remove_unknown_conf_entry_options( ) async def test_link_config_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test manual and dynamically setup entities are linked to the config entry.""" # set up manual item - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # set up item through discovery config_discovery = { "name": "test_discovery", "unique_id": "test_discovery_unique456", - "command_topic": "test-topic_discovery", + "state_topic": "test-topic_discovery", } async_fire_mqtt_message( - hass, "homeassistant/light/bla/config", json.dumps(config_discovery) + 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("light.test_manual") is not None - assert hass.states.get("light.test_discovery") is not None + assert hass.states.get("sensor.test_manual") is not None + assert hass.states.get("sensor.test_discovery") is not None entity_names = ["test_manual", "test_discovery"] # Check if both entities were linked to the MQTT config entry @@ -3407,7 +3348,7 @@ async def test_link_config_entry( assert _check_entities() == 1 # set up item through discovery async_fire_mqtt_message( - hass, "homeassistant/light/bla/config", json.dumps(config_discovery) + hass, "homeassistant/sensor/bla/config", json.dumps(config_discovery) ) await hass.async_block_till_done() assert _check_entities() == 2 diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index e4cf5bb8030..bf10a692b7c 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -125,10 +125,10 @@ def vacuum_platform_only(): @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -149,10 +149,10 @@ async def test_default_supported_features( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test simple commands to the vacuum.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_called_once_with( @@ -240,10 +240,10 @@ async def test_all_commands( ], ) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() @@ -299,10 +299,10 @@ async def test_commands_without_supported_features( ], ) async def test_attributes_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "battery_level": 54, @@ -325,10 +325,10 @@ async def test_attributes_without_supported_features( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "battery_level": 54, @@ -365,10 +365,10 @@ async def test_status( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_status_battery( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "battery_level": 54 @@ -383,11 +383,11 @@ async def test_status_battery( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_status_cleaning( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "cleaning": true @@ -402,10 +402,10 @@ async def test_status_cleaning( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "docked": true @@ -420,10 +420,10 @@ async def test_status_docked( [DEFAULT_CONFIG_ALL_SERVICES], ) async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "charging": true @@ -435,10 +435,10 @@ async def test_status_charging( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "fan_speed": "max" @@ -450,10 +450,10 @@ async def test_status_fan_speed( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] @@ -476,13 +476,13 @@ async def test_status_fan_speed_list( ], ) async def test_status_no_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum. If the vacuum doesn't support fan speed, fan speed list should be None. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None @@ -490,10 +490,10 @@ async def test_status_no_fan_speed_list( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_error( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "error": "Error1" @@ -526,10 +526,10 @@ async def test_status_error( ], ) async def test_battery_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that you can use non-default templates for battery_level.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") state = hass.states.get("vacuum.mqtttest") @@ -539,10 +539,10 @@ async def test_battery_template( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -563,12 +563,12 @@ async def test_status_invalid_json( ) async def test_missing_templates( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test to make sure missing template is not allowed.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: some but not all values in the same group of inclusion" in caplog.text @@ -577,58 +577,58 @@ async def test_missing_templates( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN + hass, mqtt_mock_entry, vacuum.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2, MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, @@ -636,23 +636,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -661,13 +661,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -676,13 +676,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -711,40 +711,40 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, vacuum.DOMAIN) async def test_discovery_removal_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered vacuum.""" data = json.dumps(DEFAULT_CONFIG_2[mqtt.DOMAIN][vacuum.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data ) async def test_discovery_update_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" @@ -754,7 +754,7 @@ async def test_discovery_update_unchanged_vacuum( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, @@ -765,55 +765,55 @@ async def test_discovery_update_unchanged_vacuum( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -829,7 +829,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, config, ["test-topic", "avty-topic"], @@ -837,16 +837,16 @@ async def test_entity_id_update_subscriptions( async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" config = { @@ -862,7 +862,7 @@ async def test_entity_debug_info_message( } await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, config, vacuum.SERVICE_TURN_ON, @@ -911,7 +911,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -932,7 +932,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -977,7 +977,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1002,7 +1002,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, config, topic, @@ -1015,9 +1015,9 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 6ad52103a06..22330d65c2a 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -247,12 +247,12 @@ def light_platform_only(): ) async def test_fail_setup_if_no_command_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text @@ -274,10 +274,10 @@ async def test_fail_setup_if_no_command_topic( ], ) async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test if there is no color and brightness if no topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -352,12 +352,12 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( ], ) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling of the state via topic.""" color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -480,11 +480,11 @@ async def test_controlling_state_via_topic( ) async def test_invalid_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of empty data via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -597,10 +597,10 @@ async def test_invalid_state_via_topic( ], ) async def test_brightness_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the brightness controlling scale.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -646,10 +646,10 @@ async def test_brightness_controlling_scale( ], ) async def test_brightness_from_rgb_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the brightness controlling scale.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.async_block_till_done() state = hass.states.get("light.test") @@ -728,12 +728,12 @@ async def test_brightness_from_rgb_controlling_scale( ], ) async def test_controlling_state_via_topic_with_templates( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the state with a template.""" color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -794,6 +794,19 @@ async def test_controlling_state_via_topic_with_templates( assert state.attributes.get(light.ATTR_COLOR_MODE) == "xy" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + async_fire_mqtt_message(hass, "test_light_rgb/brightness/status", '{"hello": 100}') + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 100 + + async_fire_mqtt_message(hass, "test_light_rgb/brightness/status", '{"hello": 50}') + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 50 + + # test zero brightness received is ignored + async_fire_mqtt_message(hass, "test_light_rgb/brightness/status", '{"hello": 0}') + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 50 + @pytest.mark.parametrize( "hass_config", @@ -821,7 +834,7 @@ async def test_controlling_state_via_topic_with_templates( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] @@ -838,7 +851,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1009,10 +1022,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_mqtt_rgb_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of RGB command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1052,10 +1065,10 @@ async def test_sending_mqtt_rgb_command_with_template( ], ) async def test_sending_mqtt_rgbw_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBW command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1095,10 +1108,10 @@ async def test_sending_mqtt_rgbw_command_with_template( ], ) async def test_sending_mqtt_rgbww_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBWW command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1137,10 +1150,10 @@ async def test_sending_mqtt_rgbww_command_with_template( ], ) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of Color Temp command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1176,10 +1189,10 @@ async def test_sending_mqtt_color_temp_command_with_template( ], ) async def test_on_command_first( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command being sent before brightness.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1217,10 +1230,10 @@ async def test_on_command_first( ], ) async def test_on_command_last( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command being sent after brightness.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1260,10 +1273,10 @@ async def test_on_command_last( ], ) async def test_on_command_brightness( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command being sent as only brightness.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1322,10 +1335,10 @@ async def test_on_command_brightness( ], ) async def test_on_command_brightness_scaled( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test brightness scale.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1395,10 +1408,10 @@ async def test_on_command_brightness_scaled( ], ) async def test_on_command_rgb( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1491,10 +1504,10 @@ async def test_on_command_rgb( ], ) async def test_on_command_rgbw( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1587,10 +1600,10 @@ async def test_on_command_rgbw( ], ) async def test_on_command_rgbww( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1684,10 +1697,10 @@ async def test_on_command_rgbww( ], ) async def test_on_command_rgb_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode with RGB template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1727,10 +1740,10 @@ async def test_on_command_rgb_template( ], ) async def test_on_command_rgbw_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode with RGBW template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1770,10 +1783,10 @@ async def test_on_command_rgbw_template( ], ) async def test_on_command_rgbww_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode with RGBWW template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1824,12 +1837,12 @@ async def test_on_command_rgbww_template( ], ) async def test_on_command_white( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test sending commands for RGB + white light.""" color_modes = ["rgb", "white"] - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1916,12 +1929,12 @@ async def test_on_command_white( ], ) async def test_explicit_color_mode( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test explicit color mode over mqtt.""" color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2062,12 +2075,12 @@ async def test_explicit_color_mode( ], ) async def test_explicit_color_mode_templated( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test templated explicit color mode over mqtt.""" color_modes = ["color_temp", "hs"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2157,12 +2170,12 @@ async def test_explicit_color_mode_templated( ], ) async def test_white_state_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test state updates for RGB + white light.""" color_modes = ["rgb", "white"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2213,10 +2226,10 @@ async def test_white_state_update( ], ) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test effect.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2242,58 +2255,58 @@ async def test_effect( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN + hass, mqtt_mock_entry, light.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED, @@ -2301,23 +2314,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2326,13 +2339,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2341,13 +2354,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2378,15 +2391,15 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, light.DOMAIN) async def test_discovery_removal_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered light.""" @@ -2395,18 +2408,16 @@ async def test_discovery_removal_light( ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, light.DOMAIN, data) async def test_discovery_ignores_extra_keys( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test discovery ignores extra keys that are not blocked.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # inserted `platform` key should be ignored data = ( '{ "name": "Beer",' ' "platform": "mqtt",' ' "command_topic": "test_topic"}' @@ -2420,7 +2431,7 @@ async def test_discovery_ignores_extra_keys( async def test_discovery_update_light_topic_and_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -2665,7 +2676,7 @@ async def test_discovery_update_light_topic_and_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, config1, @@ -2677,7 +2688,7 @@ async def test_discovery_update_light_topic_and_template( async def test_discovery_update_light_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -2880,7 +2891,7 @@ async def test_discovery_update_light_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, config1, @@ -2892,7 +2903,7 @@ async def test_discovery_update_light_template( async def test_discovery_update_unchanged_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -2906,7 +2917,7 @@ async def test_discovery_update_unchanged_light( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, data1, @@ -2917,7 +2928,7 @@ async def test_discovery_update_unchanged_light( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -2928,71 +2939,71 @@ async def test_discovery_broken( ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, light.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON, @@ -3015,10 +3026,10 @@ async def test_entity_debug_info_message( ], ) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -3113,7 +3124,7 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -3133,7 +3144,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -3189,7 +3200,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -3211,7 +3222,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, config, topic, @@ -3230,7 +3241,7 @@ async def test_encoding_subscribable_topics( ) async def test_encoding_subscribable_topics_brightness( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, topic: str, value: str, @@ -3244,7 +3255,7 @@ async def test_encoding_subscribable_topics_brightness( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, config, topic, @@ -3274,10 +3285,10 @@ async def test_encoding_subscribable_topics_brightness( ], ) async def test_sending_mqtt_brightness_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of Brightness command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3318,10 +3329,10 @@ async def test_sending_mqtt_brightness_command_with_template( ], ) async def test_sending_mqtt_effect_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of Effect command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3362,10 +3373,10 @@ async def test_sending_mqtt_effect_command_with_template( ], ) async def test_sending_mqtt_hs_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of HS Color command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3405,10 +3416,10 @@ async def test_sending_mqtt_hs_command_with_template( ], ) async def test_sending_mqtt_xy_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of XY Color command with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3430,21 +3441,21 @@ async def test_sending_mqtt_xy_command_with_template( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = light.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 1b24f7636f5..6c653045aa2 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -192,12 +192,12 @@ class JsonValidator: ) async def test_fail_setup_if_no_command_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text @@ -215,12 +215,12 @@ async def test_fail_setup_if_no_command_topic( ) async def test_fail_setup_if_color_mode_deprecated( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails if color mode is combined with deprecated config keys.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: color_mode must not be combined with any of" in caplog.text @@ -258,16 +258,59 @@ async def test_fail_setup_if_color_mode_deprecated( ) async def test_fail_setup_if_color_modes_invalid( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, error: str, ) -> None: """Test if setup fails if supported color modes is invalid.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert error in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "supported_color_modes": "color_temp", + } + } + } + ], +) +async def test_single_color_mode( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup with single color_mode.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + + async_fire_mqtt_message( + hass, + "test_light", + '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + ) + color_modes = [light.ColorMode.COLOR_TEMP] + state = hass.states.get("light.test") + assert state.state == STATE_ON + + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 + assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] + + @pytest.mark.parametrize( "hass_config", [ @@ -284,10 +327,10 @@ async def test_fail_setup_if_color_modes_invalid( ], ) async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test legacy RGB light flags expected features and color modes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") color_modes = [light.ColorMode.HS] @@ -312,10 +355,10 @@ async def test_legacy_rgb_light( ], ) async def test_no_color_brightness_color_temp_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for no RGB, brightness, color temp, effector XY.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -373,10 +416,10 @@ async def test_no_color_brightness_color_temp_if_no_topics( ], ) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling of the state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -493,6 +536,37 @@ async def test_controlling_state_via_topic( light_state = hass.states.get("light.test") assert light_state.attributes.get("effect") == "colorloop" + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness":128,' + '"color_temp":155,' + '"effect":"colorloop"}', + ) + light_state = hass.states.get("light.test") + assert light_state.state == STATE_ON + assert light_state.attributes.get("brightness") == 128 + + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"OFF","brightness":0}', + ) + light_state = hass.states.get("light.test") + assert light_state.state == STATE_OFF + assert light_state.attributes.get("brightness") is None + + # test previous zero brightness received was ignored and brightness is restored + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON"}') + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 128 + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON","brightness":0}') + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 128 + @pytest.mark.parametrize( "hass_config", @@ -504,12 +578,12 @@ async def test_controlling_state_via_topic( ) async def test_controlling_state_via_topic2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling of the state via topic for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -681,7 +755,7 @@ async def test_controlling_state_via_topic2( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -696,7 +770,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -834,7 +908,7 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_mqtt_commands_and_optimistic2( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] @@ -851,7 +925,7 @@ async def test_sending_mqtt_commands_and_optimistic2( ) mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1063,10 +1137,10 @@ async def test_sending_mqtt_commands_and_optimistic2( ], ) async def test_sending_hs_color( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends hs color parameters.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1125,11 +1199,11 @@ async def test_sending_hs_color( ], ) async def test_sending_rgb_color_no_brightness( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1184,10 +1258,10 @@ async def test_sending_rgb_color_no_brightness( ], ) async def test_sending_rgb_color_no_brightness2( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends rgb color parameters.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1264,10 +1338,10 @@ async def test_sending_rgb_color_no_brightness2( ], ) async def test_sending_rgb_color_with_brightness( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends rgb color parameters.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1333,11 +1407,11 @@ async def test_sending_rgb_color_with_brightness( ], ) async def test_sending_rgb_color_with_scaled_brightness( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1405,10 +1479,10 @@ async def test_sending_rgb_color_with_scaled_brightness( ], ) async def test_sending_scaled_white( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with scaled white.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1449,10 +1523,10 @@ async def test_sending_scaled_white( ], ) async def test_sending_xy_color( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test light.turn_on with hs color sends xy color parameters.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1511,10 +1585,10 @@ async def test_sending_xy_color( ], ) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for effect being sent when included.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1578,10 +1652,10 @@ async def test_effect( ], ) async def test_flash_short_and_long( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for flash length being sent when included.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1641,10 +1715,10 @@ async def test_flash_short_and_long( ], ) async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for transition time being sent when included.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1693,10 +1767,10 @@ async def test_transition( ], ) async def test_brightness_scale( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for brightness scaling.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1741,10 +1815,10 @@ async def test_brightness_scale( ], ) async def test_white_scale( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for white scaling.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1801,10 +1875,10 @@ async def test_white_scale( ], ) async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that invalid color/brightness/etc. values are ignored.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1912,58 +1986,58 @@ async def test_invalid_values( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN + hass, mqtt_mock_entry, light.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED, @@ -1971,23 +2045,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -1996,13 +2070,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2011,13 +2085,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2050,22 +2124,22 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, light.DOMAIN) async def test_discovery_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered mqtt_json lights.""" data = '{ "name": "test", "schema": "json", "command_topic": "test_topic" }' await help_test_discovery_removal( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, data, @@ -2074,7 +2148,7 @@ async def test_discovery_removal( async def test_discovery_update_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -2092,7 +2166,7 @@ async def test_discovery_update_light( } await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, config1, @@ -2102,7 +2176,7 @@ async def test_discovery_update_light( async def test_discovery_update_unchanged_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -2117,7 +2191,7 @@ async def test_discovery_update_unchanged_light( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, data1, @@ -2128,7 +2202,7 @@ async def test_discovery_update_unchanged_light( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -2141,7 +2215,7 @@ async def test_discovery_broken( ) await help_test_discovery_broken( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, data1, @@ -2150,78 +2224,78 @@ async def test_discovery_broken( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON, @@ -2247,10 +2321,10 @@ async def test_entity_debug_info_message( ], ) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -2282,7 +2356,7 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -2300,7 +2374,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -2338,7 +2412,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -2358,7 +2432,7 @@ async def test_encoding_subscribable_topics( ] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, config, topic, @@ -2372,9 +2446,9 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = light.DOMAIN assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 166687b8595..ba8414e252a 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -141,12 +141,12 @@ def light_platform_only(): ) async def test_setup_fails( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test that setup fails with missing required configuration items.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert "Invalid config" in caplog.text @@ -170,10 +170,10 @@ async def test_setup_fails( ], ) async def test_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test RGB light flags brightness support.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -183,6 +183,45 @@ async def test_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } + } + } + ], +) +async def test_single_color_mode( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the color mode when we only have one supported color_mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + async_fire_mqtt_message(hass, "test_light", "on,50,192") + color_modes = [light.ColorMode.COLOR_TEMP] + state = hass.states.get("light.test") + assert state.state == STATE_ON + + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 + assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] + + @pytest.mark.parametrize( "hass_config", [ @@ -207,10 +246,10 @@ async def test_rgb_light( ], ) async def test_state_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test state change via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -270,10 +309,10 @@ async def test_state_change_via_topic( ], ) async def test_state_brightness_color_effect_temp_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test state, bri, color, effect, color temp change.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -330,6 +369,12 @@ async def test_state_brightness_color_effect_temp_change_via_topic( light_state = hass.states.get("light.test") assert light_state.attributes["brightness"] == 100 + # ignore a zero brightness + async_fire_mqtt_message(hass, "test_light_rgb", "on,0") + + light_state = hass.states.get("light.test") + assert light_state.attributes["brightness"] == 100 + # change the color temp async_fire_mqtt_message(hass, "test_light_rgb", "on,,195") @@ -382,7 +427,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -397,7 +442,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -524,10 +569,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_mqtt_commands_non_optimistic_brightness_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -633,10 +678,10 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( ], ) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test effect sent over MQTT in optimistic mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -687,10 +732,10 @@ async def test_effect( ], ) async def test_flash( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test flash sent over MQTT in optimistic mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -738,10 +783,10 @@ async def test_flash( ], ) async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test for transition time being sent when included.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -796,11 +841,11 @@ async def test_transition( ], ) async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that invalid values are ignored.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -864,58 +909,58 @@ async def test_invalid_values( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN + hass, mqtt_mock_entry, light.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED, @@ -923,23 +968,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -948,13 +993,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -963,13 +1008,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -1006,15 +1051,15 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, light.DOMAIN) async def test_discovery_removal( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered mqtt_json lights.""" @@ -1025,14 +1070,12 @@ async def test_discovery_removal( ' "command_on_template": "on",' ' "command_off_template": "off"}' ) - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, light.DOMAIN, data) async def test_discovery_update_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -1053,13 +1096,13 @@ async def test_discovery_update_light( "command_off_template": "off", } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, light.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_light( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered light.""" @@ -1076,7 +1119,7 @@ async def test_discovery_update_unchanged_light( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, light.DOMAIN, data1, @@ -1087,7 +1130,7 @@ async def test_discovery_update_unchanged_light( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -1101,66 +1144,66 @@ async def test_discovery_broken( ' "command_off_template": "off"}' ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, light.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, light.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" config = { @@ -1177,7 +1220,7 @@ async def test_entity_debug_info_message( } await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, config, light.SERVICE_TURN_ON, @@ -1203,10 +1246,10 @@ async def test_entity_debug_info_message( ], ) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -1238,7 +1281,7 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -1256,7 +1299,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -1288,7 +1331,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1300,7 +1343,7 @@ async def test_encoding_subscribable_topics( config["state_template"] = "{{ value }}" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, light.DOMAIN, config, topic, @@ -1313,21 +1356,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = light.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index f13de403535..3c878a85f9e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -98,12 +98,12 @@ def lock_platform_only(): ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, payload: str, lock_state: str, ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -128,12 +128,12 @@ async def test_controlling_state_via_topic( ) async def test_controlling_non_default_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, payload: str, lock_state: str, ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -188,12 +188,12 @@ async def test_controlling_non_default_state_via_topic( ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, payload: str, lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -247,12 +247,12 @@ async def test_controlling_state_via_topic_and_json_message( ) async def test_controlling_non_default_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, payload: str, lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -281,10 +281,10 @@ async def test_controlling_non_default_state_via_topic_and_json_message( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test optimistic mode without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -332,10 +332,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_mqtt_commands_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test sending commands with template.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -392,10 +392,10 @@ async def test_sending_mqtt_commands_with_template( ], ) async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test optimistic mode without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -441,10 +441,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ], ) async def test_sending_mqtt_commands_support_open_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test open function of the lock without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -503,10 +503,10 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( ], ) async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test open function of the lock without state topic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -567,10 +567,10 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( ], ) async def test_sending_mqtt_commands_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test function of the lock with state topics.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -652,58 +652,58 @@ async def test_sending_mqtt_commands_pessimistic( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN + hass, mqtt_mock_entry, lock.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG, MQTT_LOCK_ATTRIBUTES_BLOCKED, @@ -711,23 +711,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, lock.DOMAIN, DEFAULT_CONFIG, @@ -736,13 +736,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, lock.DOMAIN, DEFAULT_CONFIG, @@ -751,12 +751,12 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, lock.DOMAIN, DEFAULT_CONFIG ) @@ -784,27 +784,25 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one lock per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, lock.DOMAIN) async def test_discovery_removal_lock( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered lock.""" data = '{ "name": "test",' ' "command_topic": "test_topic" }' - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, lock.DOMAIN, data) async def test_discovery_update_lock( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered lock.""" @@ -821,13 +819,13 @@ async def test_discovery_update_lock( "availability_topic": "availability_topic2", } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, lock.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_lock( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered lock.""" @@ -841,7 +839,7 @@ async def test_discovery_update_unchanged_lock( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, lock.DOMAIN, data1, @@ -852,78 +850,78 @@ async def test_discovery_update_unchanged_lock( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, lock.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT lock device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT lock device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG, SERVICE_LOCK, @@ -945,7 +943,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -959,7 +957,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -989,7 +987,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -998,7 +996,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, lock.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][lock.DOMAIN], topic, @@ -1010,21 +1008,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = lock.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = lock.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 18d59f98675..c7285f0fa5f 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -33,14 +33,14 @@ from tests.typing import MqttMockHAClientGenerator @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_availability_with_shared_state_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the state is not changed twice. When an entity with a shared state_topic and availability_topic becomes available The state should only change once. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events = [] diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index bd28d75ac9a..f12f5eca8b6 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -18,7 +18,6 @@ from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, - NumberDeviceClass, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -76,45 +75,80 @@ def number_platform_only(): @pytest.mark.parametrize( - "hass_config", + ("hass_config", "device_class", "unit_of_measurement", "values"), [ - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": "test/state_number", - "command_topic": "test/cmd_number", - "name": "Test Number", - "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, - "payload_reset": "reset!", + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + "payload_reset": "reset!", + } } - } - } + }, + "temperature", + UnitOfTemperature.CELSIUS.value, + [("10", "-12.0"), ("20.5", "-6.4")], # 10 °F -> -12 °C + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + "payload_reset": "reset!", + } + } + }, + "temperature", + UnitOfTemperature.CELSIUS.value, + [("10", "10"), ("15", "15")], + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "device_class": None, + "unit_of_measurement": None, + "payload_reset": "reset!", + } + } + }, + None, + None, + [("10", "10"), ("20", "20")], + ), ], ) async def test_run_number_setup( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_class: str | None, + unit_of_measurement: UnitOfTemperature | None, + values: list[tuple[str, str]], ) -> None: """Test that it fetches the given payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() - async_fire_mqtt_message(hass, "test/state_number", "10") + for payload, value in values: + async_fire_mqtt_message(hass, "test/state_number", payload) - await hass.async_block_till_done() + await hass.async_block_till_done() - state = hass.states.get("number.test_number") - assert state.state == "-12.0" # 10 °F -> -12 °C - assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - - async_fire_mqtt_message(hass, "test/state_number", "20.5") - - await hass.async_block_till_done() - - state = hass.states.get("number.test_number") - assert state.state == "-6.4" # 20.5 °F -> -6.4 °C - assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" + state = hass.states.get("number.test_number") + assert state.state == value + assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement async_fire_mqtt_message(hass, "test/state_number", "reset!") @@ -122,8 +156,8 @@ async def test_run_number_setup( state = hass.states.get("number.test_number") assert state.state == "unknown" - assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" + assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement @pytest.mark.parametrize( @@ -142,11 +176,11 @@ async def test_run_number_setup( ], ) async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that it fetches the given payload with a template.""" topic = "test/state_number" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, topic, '{"val":10}') @@ -186,7 +220,7 @@ async def test_value_template( ], ) async def test_restore_native_value( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that the stored native_value is restored.""" @@ -201,7 +235,7 @@ async def test_restore_native_value( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.state == "37.8" @@ -222,7 +256,7 @@ async def test_restore_native_value( ], ) async def test_run_number_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode.""" topic = "test/number" @@ -239,7 +273,7 @@ async def test_run_number_service_optimistic( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.state == "3" @@ -300,7 +334,7 @@ async def test_run_number_service_optimistic( ], ) async def test_run_number_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" @@ -316,7 +350,7 @@ async def test_run_number_service_optimistic_with_command_template( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.state == "3" @@ -379,13 +413,13 @@ async def test_run_number_service_optimistic_with_command_template( ], ) async def test_run_number_service( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" state_topic = "test/number" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -418,13 +452,13 @@ async def test_run_number_service( ], ) async def test_run_number_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/number/set" state_topic = "test/number" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -445,58 +479,58 @@ async def test_run_number_service_with_command_template( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN + hass, mqtt_mock_entry, number.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG, MQTT_NUMBER_ATTRIBUTES_BLOCKED, @@ -504,23 +538,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, number.DOMAIN, DEFAULT_CONFIG, @@ -529,13 +563,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, number.DOMAIN, DEFAULT_CONFIG, @@ -544,13 +578,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, number.DOMAIN, DEFAULT_CONFIG, @@ -581,27 +615,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one number per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, number.DOMAIN) async def test_discovery_removal_number( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered number.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][number.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, data + hass, mqtt_mock_entry, caplog, number.DOMAIN, data ) async def test_discovery_update_number( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered number.""" @@ -617,13 +651,13 @@ async def test_discovery_update_number( } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, number.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_number( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered number.""" @@ -635,7 +669,7 @@ async def test_discovery_update_unchanged_number( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, number.DOMAIN, data1, @@ -646,7 +680,7 @@ async def test_discovery_update_unchanged_number( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -656,71 +690,71 @@ async def test_discovery_broken( ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, number.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT number device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT number device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG, SERVICE_SET_VALUE, @@ -748,10 +782,10 @@ async def test_entity_debug_info_message( ], ) async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test min/max/step attributes.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MIN) == 5 @@ -777,12 +811,12 @@ async def test_min_max_step_attributes( ) async def test_invalid_min_max_attributes( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid min/max attributes.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text @@ -801,10 +835,10 @@ async def test_invalid_min_max_attributes( ], ) async def test_default_mode( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test default mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == "auto" @@ -856,11 +890,11 @@ async def test_default_mode( ) async def test_mode( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, mode, ) -> None: """Test mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == mode @@ -899,15 +933,15 @@ async def test_mode( ) async def test_invalid_mode( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, valid: bool, ) -> None: """Test invalid mode.""" if valid: - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() return with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize( @@ -927,12 +961,12 @@ async def test_invalid_mode( async def test_mqtt_payload_not_a_number_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test warning for MQTT payload which is not a number.""" topic = "test/state_number" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, topic, "not_a_number") @@ -960,13 +994,13 @@ async def test_mqtt_payload_not_a_number_warning( async def test_mqtt_payload_out_of_range_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when MQTT payload is out of min/max range.""" topic = "test/state_number" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, topic, "115.5") @@ -991,7 +1025,7 @@ async def test_mqtt_payload_out_of_range_error( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -1005,7 +1039,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -1036,7 +1070,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1045,7 +1079,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, number.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][number.DOMAIN], topic, @@ -1057,21 +1091,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = number.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = number.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 56350c90c0d..9c4b5b53be9 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -58,13 +58,13 @@ def scene_platform_only(): ], ) async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending MQTT commands.""" fake_state = State("scene.test", STATE_UNKNOWN) mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("scene.test") assert state.state == STATE_UNKNOWN @@ -79,26 +79,26 @@ async def test_sending_mqtt_commands( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN + hass, mqtt_mock_entry, scene.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -112,7 +112,7 @@ async def test_default_availability_payload( } await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, scene.DOMAIN, config, True, @@ -122,7 +122,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -137,7 +137,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, scene.DOMAIN, config, True, @@ -168,27 +168,25 @@ async def test_custom_availability_payload( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one scene per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, scene.DOMAIN) async def test_discovery_removal_scene( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered scene.""" data = '{ "name": "test",' ' "command_topic": "test_topic" }' - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, scene.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, scene.DOMAIN, data) async def test_discovery_update_payload( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered scene.""" @@ -201,7 +199,7 @@ async def test_discovery_update_payload( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, scene.DOMAIN, config1, @@ -211,7 +209,7 @@ async def test_discovery_update_payload( async def test_discovery_update_unchanged_scene( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered scene.""" @@ -221,7 +219,7 @@ async def test_discovery_update_unchanged_scene( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, scene.DOMAIN, data1, @@ -232,14 +230,14 @@ async def test_discovery_update_unchanged_scene( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, scene.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, scene.DOMAIN, data1, data2 ) @@ -255,21 +253,21 @@ async def test_reloadable( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = scene.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = scene.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index bbda9c88deb..583e65bc61c 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -99,11 +99,11 @@ def _test_run_select_setup_params( ) async def test_run_select_setup( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, ) -> None: """Test that it fetches the given payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, topic, "milk") @@ -137,10 +137,10 @@ async def test_run_select_setup( ], ) async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that it fetches the given payload with a template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test/select_stat", '{"val":"milk"}') @@ -179,13 +179,13 @@ async def test_value_template( ], ) async def test_run_select_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode.""" fake_state = State("select.test_select", "milk") mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -220,13 +220,13 @@ async def test_run_select_service_optimistic( ], ) async def test_run_select_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode and with a command_template.""" fake_state = State("select.test_select", "milk") mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -263,13 +263,13 @@ async def test_run_select_service_optimistic_with_command_template( ], ) async def test_run_select_service( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" state_topic = "test/select" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -303,13 +303,13 @@ async def test_run_select_service( ], ) async def test_run_select_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/select/set" state_topic = "test/select" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -328,58 +328,58 @@ async def test_run_select_service_with_command_template( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN + hass, mqtt_mock_entry, select.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG, MQTT_SELECT_ATTRIBUTES_BLOCKED, @@ -387,23 +387,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, select.DOMAIN, DEFAULT_CONFIG, @@ -412,13 +412,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, select.DOMAIN, DEFAULT_CONFIG, @@ -427,13 +427,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, select.DOMAIN, DEFAULT_CONFIG, @@ -466,27 +466,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one select per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, select.DOMAIN) async def test_discovery_removal_select( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered select.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][select.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, data + hass, mqtt_mock_entry, caplog, select.DOMAIN, data ) async def test_discovery_update_select( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered select.""" @@ -504,13 +504,13 @@ async def test_discovery_update_select( } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, select.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_select( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered select.""" @@ -520,7 +520,7 @@ async def test_discovery_update_unchanged_select( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, select.DOMAIN, data1, @@ -531,7 +531,7 @@ async def test_discovery_update_unchanged_select( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -539,71 +539,71 @@ async def test_discovery_broken( data2 = '{ "name": "Milk", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, select.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT select device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT select device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, select.DOMAIN, DEFAULT_CONFIG, select.SERVICE_SELECT_OPTION, @@ -638,11 +638,11 @@ def _test_options_attributes_options_config( ) async def test_options_attributes( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, options: list[str], ) -> None: """Test options attribute.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("select.test_select") assert state.attributes.get(ATTR_OPTIONS) == options @@ -666,10 +666,10 @@ async def test_options_attributes( async def test_mqtt_payload_not_an_option_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test warning for MQTT payload which is not a valid option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test/select_stat", "öl") @@ -695,7 +695,7 @@ async def test_mqtt_payload_not_an_option_warning( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -710,7 +710,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -741,7 +741,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -752,7 +752,7 @@ async def test_encoding_subscribable_topics( config["options"] = ["milk", "beer"] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, select.DOMAIN, config, topic, @@ -764,31 +764,31 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = select.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = select.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) async def test_persistent_state_after_reconfig( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test of the state is persistent after reconfiguring the select options.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() discovery_data = '{ "name": "Milk", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' await help_test_discovery_setup(hass, SELECT_DOMAIN, discovery_data, "milk") diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 01c897a9d86..64499f11140 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -99,10 +99,10 @@ def sensor_platform_only(): ], ) async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic", "100.22") state = hass.states.get("sensor.test") @@ -217,7 +217,7 @@ async def test_setting_sensor_value_via_mqtt_message( ) async def test_setting_sensor_native_value_handling_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, device_class: sensor.SensorDeviceClass | None, native_value: str, @@ -225,7 +225,7 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( log: bool, ) -> None: """Test the setting of the value via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic", native_value) state = hass.states.get("sensor.test") @@ -253,11 +253,11 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( ) async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of a numeric sensor value via MQTT.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # float value async_fire_mqtt_message(hass, "test-topic", '{ "power": 45.3, "current": 5.24 }') @@ -308,10 +308,10 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( ], ) async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the expiration of the value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE @@ -342,10 +342,10 @@ async def test_setting_sensor_value_expires_availability_topic( ], ) async def test_setting_sensor_value_expires( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the expiration of the value.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("sensor.test") @@ -420,10 +420,10 @@ async def expires_helper(hass: HomeAssistant) -> None: ], ) async def test_setting_sensor_value_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT with JSON payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }') state = hass.states.get("sensor.test") @@ -452,10 +452,10 @@ async def test_setting_sensor_value_via_mqtt_json_message( ], ) async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT with fall back to current state.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' @@ -488,11 +488,11 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st ) async def test_setting_sensor_last_reset_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") @@ -525,10 +525,10 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, datestring, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "last-reset-topic", datestring) state = hass.states.get("sensor.test") @@ -553,10 +553,10 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( ], ) async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "last-reset-topic", "") state = hass.states.get("sensor.test") @@ -581,10 +581,10 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( ], ) async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of the value via MQTT with JSON payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' @@ -625,12 +625,12 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( ) async def test_setting_sensor_last_reset_via_mqtt_json_message_2( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the setting of the value via MQTT with JSON payload.""" await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, @@ -662,10 +662,10 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( ], ) async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test force update option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events: list[Event] = [] @@ -700,10 +700,10 @@ async def test_force_update_disabled( ], ) async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test force update option.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() events: list[Event] = [] @@ -724,57 +724,57 @@ async def test_force_update_enabled( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN + hass, mqtt_mock_entry, sensor.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_all( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_all( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_any( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_any( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -791,20 +791,20 @@ async def test_default_availability_list_single( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_discovery_update_availability( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability discovery update.""" await help_test_discovery_update_availability( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -824,12 +824,12 @@ async def test_discovery_update_availability( ) async def test_invalid_device_class( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text ) @@ -851,17 +851,18 @@ async def test_invalid_device_class( "name": "Test 3", "state_topic": "test-topic", "device_class": None, + "unit_of_measurement": None, }, ] } } ], ) -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +async def test_valid_device_class_and_uom( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: - """Test device_class option with valid values.""" - await mqtt_mock_entry_no_yaml_config() + """Test device_class option with valid values and test with an empty unit of measurement.""" + await mqtt_mock_entry() state = hass.states.get("sensor.test_1") assert state.attributes["device_class"] == "temperature" @@ -887,12 +888,12 @@ async def test_valid_device_class( ) async def test_invalid_state_class( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test state_class option with invalid value.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() assert ( "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text ) @@ -921,10 +922,10 @@ async def test_invalid_state_class( ], ) async def test_valid_state_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test state_class option with valid values.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("sensor.test_1") assert state.attributes["state_class"] == "measurement" @@ -935,21 +936,21 @@ async def test_valid_state_class( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG, MQTT_SENSOR_ATTRIBUTES_BLOCKED, @@ -957,23 +958,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, DEFAULT_CONFIG, @@ -982,13 +983,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, DEFAULT_CONFIG, @@ -997,13 +998,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, DEFAULT_CONFIG, @@ -1032,27 +1033,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, sensor.DOMAIN) async def test_discovery_removal_sensor( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered sensor.""" data = '{ "name": "test", "state_topic": "test_topic" }' await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, data + hass, mqtt_mock_entry, caplog, sensor.DOMAIN, data ) async def test_discovery_update_sensor_topic_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered sensor.""" @@ -1077,7 +1078,7 @@ async def test_discovery_update_sensor_topic_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, config1, @@ -1089,7 +1090,7 @@ async def test_discovery_update_sensor_topic_template( async def test_discovery_update_sensor_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered sensor.""" @@ -1112,7 +1113,7 @@ async def test_discovery_update_sensor_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, config1, @@ -1124,7 +1125,7 @@ async def test_discovery_update_sensor_template( async def test_discovery_update_unchanged_sensor( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered sensor.""" @@ -1134,7 +1135,7 @@ async def test_discovery_update_unchanged_sensor( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, sensor.DOMAIN, data1, @@ -1145,76 +1146,76 @@ async def test_discovery_update_unchanged_sensor( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "state_topic": "test_topic#" }' data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, sensor.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_hub( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor device registry integration.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) hub = registry.async_get_or_create( config_entry_id="123", @@ -1241,66 +1242,66 @@ async def test_entity_device_info_with_hub( async def test_entity_debug_info( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor debug info.""" await help_test_entity_debug_info( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_max_messages( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor debug info.""" await help_test_entity_debug_info_max_messages( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG, None + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG, None ) async def test_entity_debug_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor debug info.""" await help_test_entity_debug_info_remove( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_update_entity_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT sensor debug info.""" await help_test_entity_debug_info_update_entity_id( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_entity_disabled_by_default( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test entity disabled by default.""" await help_test_entity_disabled_by_default( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) @pytest.mark.no_fail_on_log_exception async def test_entity_category( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test entity category.""" await help_test_entity_category( - hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -1325,10 +1326,10 @@ async def test_entity_category( ], ) async def test_value_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the access to attributes in value_template via the entity_id.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -1373,7 +1374,7 @@ async def test_reloadable( ) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, @@ -1382,7 +1383,7 @@ async def test_cleanup_triggers_and_restoring_state( """Test cleanup old triggers at reloading and restoring the state.""" freezer.move_to("2022-02-02 12:01:00+01:00") - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") assert state.state == "38" # 100 °F -> 38 °C @@ -1429,7 +1430,7 @@ async def test_cleanup_triggers_and_restoring_state( ) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" @@ -1444,7 +1445,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( fake_extra_data = MagicMock() mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("sensor.test3") assert state.state == STATE_UNAVAILABLE @@ -1458,7 +1459,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1467,7 +1468,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][sensor.DOMAIN], topic, @@ -1480,21 +1481,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = sensor.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 7b20e802a5c..76a9cb9c6f6 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -103,10 +103,10 @@ async def async_turn_off( ], ) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -140,11 +140,11 @@ async def test_controlling_state_via_topic( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending MQTT commands in optimistic mode.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -187,11 +187,11 @@ async def test_sending_mqtt_commands_and_optimistic( ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -230,11 +230,11 @@ async def test_controlling_state_via_topic_and_json_message( ) async def test_controlling_state_and_attributes_with_json_message_without_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling state via topic and JSON message without a value template.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -324,10 +324,10 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa ], ) async def test_filtering_not_supported_attributes_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags optimistic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state1 = hass.states.get("siren.test1") assert state1.state == STATE_OFF @@ -422,10 +422,10 @@ async def test_filtering_not_supported_attributes_optimistic( ], ) async def test_filtering_not_supported_attributes_via_state( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags via state.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state1 = hass.states.get("siren.test1") assert state1.state == STATE_UNKNOWN @@ -479,26 +479,26 @@ async def test_filtering_not_supported_attributes_via_state( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN + hass, mqtt_mock_entry, siren.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -514,7 +514,7 @@ async def test_default_availability_payload( } await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, siren.DOMAIN, config, True, @@ -524,7 +524,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -541,7 +541,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, siren.DOMAIN, config, True, @@ -569,10 +569,10 @@ async def test_custom_availability_payload( ], ) async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the state payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -590,41 +590,41 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG, {} + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG, {} ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, DEFAULT_CONFIG, @@ -633,13 +633,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, DEFAULT_CONFIG, @@ -648,13 +648,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, DEFAULT_CONFIG, @@ -685,15 +685,15 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one siren per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, siren.DOMAIN) async def test_discovery_removal_siren( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered siren.""" @@ -702,14 +702,12 @@ async def test_discovery_removal_siren( ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, siren.DOMAIN, data) async def test_discovery_update_siren_topic_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered siren.""" @@ -736,7 +734,7 @@ async def test_discovery_update_siren_topic_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, config1, @@ -748,7 +746,7 @@ async def test_discovery_update_siren_topic_template( async def test_discovery_update_siren_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered siren.""" @@ -773,7 +771,7 @@ async def test_discovery_update_siren_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, config1, @@ -809,10 +807,10 @@ async def test_discovery_update_siren_template( ) async def test_command_templates( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test siren with command templates optimistic.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state1 = hass.states.get("siren.beer") assert state1.state == STATE_OFF @@ -875,7 +873,7 @@ async def test_command_templates( async def test_discovery_update_unchanged_siren( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered siren.""" @@ -890,7 +888,7 @@ async def test_discovery_update_unchanged_siren( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, siren.DOMAIN, data1, @@ -901,7 +899,7 @@ async def test_discovery_update_unchanged_siren( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -912,71 +910,71 @@ async def test_discovery_broken( ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, siren.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT siren device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT siren device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG, siren.SERVICE_TURN_ON, @@ -1005,7 +1003,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -1020,7 +1018,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -1050,7 +1048,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -1059,7 +1057,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, siren.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN], topic, @@ -1071,21 +1069,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = siren.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = siren.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index dffaebca172..203f5d55b95 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -105,10 +105,10 @@ def vacuum_platform_only(): @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -118,10 +118,10 @@ async def test_default_supported_features( @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test simple commands send to the vacuum.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -201,10 +201,10 @@ async def test_all_commands( ], ) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -254,10 +254,10 @@ async def test_commands_without_supported_features( @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN @@ -310,10 +310,10 @@ async def test_status( ], ) async def test_no_fan_vacuum( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum when fan is not supported.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() message = """{ "battery_level": 54, @@ -357,10 +357,10 @@ async def test_no_fan_vacuum( @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) @pytest.mark.no_fail_on_log_exception async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -369,58 +369,58 @@ async def test_status_invalid_json( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN + hass, mqtt_mock_entry, vacuum.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2, MQTT_VACUUM_ATTRIBUTES_BLOCKED, @@ -428,23 +428,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -453,13 +453,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -468,13 +468,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -505,40 +505,40 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, vacuum.DOMAIN) async def test_discovery_removal_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered vacuum.""" data = '{ "schema": "state", "name": "test", "command_topic": "test_topic"}' await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data ) async def test_discovery_update_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" config1 = {"schema": "state", "name": "Beer", "command_topic": "test_topic"} config2 = {"schema": "state", "name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_vacuum( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" @@ -548,7 +548,7 @@ async def test_discovery_update_unchanged_vacuum( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, @@ -559,78 +559,78 @@ async def test_discovery_update_unchanged_vacuum( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic#"}' data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2, vacuum.SERVICE_START, @@ -681,7 +681,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -707,7 +707,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -748,7 +748,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -757,7 +757,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN], topic, @@ -770,9 +770,9 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index f720b44321e..fe8c9fb6101 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -23,11 +23,11 @@ def no_platforms(): async def test_subscribe_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test subscription to topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() calls1 = [] @callback @@ -76,11 +76,11 @@ async def test_subscribe_topics( async def test_modify_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test modification of topics.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() calls1 = [] @callback @@ -143,11 +143,11 @@ async def test_modify_topics( async def test_qos_encoding_default( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test default qos and encoding.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() @callback def msg_callback(*args): @@ -165,11 +165,11 @@ async def test_qos_encoding_default( async def test_qos_encoding_custom( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test custom qos and encoding.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() @callback def msg_callback(*args): @@ -194,11 +194,11 @@ async def test_qos_encoding_custom( async def test_no_change( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test subscription to topics without change.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() calls = [] diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 79f2bcc4a7c..b06cfa34442 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -62,31 +62,51 @@ def switch_platform_only(): @pytest.mark.parametrize( - "hass_config", + ("hass_config", "device_class"), [ - { - mqtt.DOMAIN: { - switch.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_on": 1, - "payload_off": 0, - "device_class": "switch", + ( + { + mqtt.DOMAIN: { + switch.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_on": 1, + "payload_off": 0, + "device_class": "switch", + } } - } - } + }, + "switch", + ), + ( + { + mqtt.DOMAIN: { + switch.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_on": 1, + "payload_off": 0, + "device_class": None, + } + } + }, + None, + ), ], ) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_class: str | None, ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_DEVICE_CLASS) == "switch" + assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", "1") @@ -122,13 +142,13 @@ async def test_controlling_state_via_topic( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending MQTT commands in optimistic mode.""" fake_state = State("switch.test", "on") mock_restore_cache(hass, (fake_state,)) - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("switch.test") assert state.state == STATE_ON @@ -166,10 +186,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_sending_inital_state_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the initial state in optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -194,10 +214,10 @@ async def test_sending_inital_state_and_optimistic( ], ) async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic and JSON message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -220,26 +240,26 @@ async def test_controlling_state_via_topic_and_json_message( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN + hass, mqtt_mock_entry, switch.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -255,7 +275,7 @@ async def test_default_availability_payload( } await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, switch.DOMAIN, config, True, @@ -265,7 +285,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -282,7 +302,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, switch.DOMAIN, config, True, @@ -310,10 +330,10 @@ async def test_custom_availability_payload( ], ) async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the state payload.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -331,41 +351,41 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG, {} + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG, {} ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, DEFAULT_CONFIG, @@ -374,13 +394,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, DEFAULT_CONFIG, @@ -389,13 +409,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, DEFAULT_CONFIG, @@ -426,15 +446,15 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one switch per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, switch.DOMAIN) async def test_discovery_removal_switch( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered switch.""" @@ -444,13 +464,13 @@ async def test_discovery_removal_switch( ' "command_topic": "test_topic" }' ) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, data + hass, mqtt_mock_entry, caplog, switch.DOMAIN, data ) async def test_discovery_update_switch_topic_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered switch.""" @@ -477,7 +497,7 @@ async def test_discovery_update_switch_topic_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, config1, @@ -489,7 +509,7 @@ async def test_discovery_update_switch_topic_template( async def test_discovery_update_switch_template( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered switch.""" @@ -514,7 +534,7 @@ async def test_discovery_update_switch_template( await help_test_discovery_update( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, config1, @@ -526,7 +546,7 @@ async def test_discovery_update_switch_template( async def test_discovery_update_unchanged_switch( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered switch.""" @@ -541,7 +561,7 @@ async def test_discovery_update_unchanged_switch( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, switch.DOMAIN, data1, @@ -552,7 +572,7 @@ async def test_discovery_update_unchanged_switch( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -563,71 +583,71 @@ async def test_discovery_broken( ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, switch.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT switch device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT switch device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG, switch.SERVICE_TURN_ON, @@ -655,7 +675,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -669,7 +689,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -699,7 +719,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -708,7 +728,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, switch.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][switch.DOMAIN], topic, @@ -720,21 +740,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = switch.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = switch.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 2f7a9d0cc06..f8c7b55f7ce 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -64,11 +64,11 @@ def tag_mock(): async def test_discover_bad_tag( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test bad discovery message.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) # Test sending bad data @@ -91,11 +91,11 @@ async def test_discover_bad_tag( async def test_if_fires_on_mqtt_message_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning, with device.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -111,11 +111,11 @@ async def test_if_fires_on_mqtt_message_with_device( async def test_if_fires_on_mqtt_message_without_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning, without device.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -130,11 +130,11 @@ async def test_if_fires_on_mqtt_message_without_device( async def test_if_fires_on_mqtt_message_with_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning, with device.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG_JSON) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -149,11 +149,11 @@ async def test_if_fires_on_mqtt_message_with_template( async def test_strip_tag_id( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test strip whitespace from tag_id.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -168,11 +168,11 @@ async def test_strip_tag_id( async def test_if_fires_on_mqtt_message_after_update_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning after update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) config1["some_future_option_1"] = "future_option_1" config2 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) @@ -217,11 +217,11 @@ async def test_if_fires_on_mqtt_message_after_update_with_device( async def test_if_fires_on_mqtt_message_after_update_without_device( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning after update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = copy.deepcopy(DEFAULT_CONFIG) config2 = copy.deepcopy(DEFAULT_CONFIG) config2["topic"] = "foobar/tag_scanned2" @@ -264,11 +264,11 @@ async def test_if_fires_on_mqtt_message_after_update_without_device( async def test_if_fires_on_mqtt_message_after_update_with_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning after update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = copy.deepcopy(DEFAULT_CONFIG_JSON) config2 = copy.deepcopy(DEFAULT_CONFIG_JSON) config2["value_template"] = "{{ value_json.RDM6300.UID }}" @@ -313,10 +313,10 @@ async def test_if_fires_on_mqtt_message_after_update_with_template( async def test_no_resubscribe_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test subscription to topics without change.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -332,11 +332,11 @@ async def test_no_resubscribe_same_topic( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning after removal.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -368,11 +368,11 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_without_device( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning not firing after removal.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -405,13 +405,13 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test tag scanning after removal.""" assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() ws_client = await hass_ws_client(hass) config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) @@ -445,10 +445,10 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT device registry integration.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) data = json.dumps( @@ -480,10 +480,10 @@ async def test_entity_device_info_with_connection( async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT device registry integration.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) data = json.dumps( @@ -513,10 +513,10 @@ async def test_entity_device_info_with_identifier( async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() registry = dr.async_get(hass) config = { @@ -553,12 +553,12 @@ async def test_cleanup_tag( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test tag discovery topic is cleaned when device is removed from registry.""" assert await async_setup_component(hass, "config", {}) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) mqtt_entry = hass.config_entries.async_entries("mqtt")[0] @@ -636,10 +636,10 @@ async def test_cleanup_tag( async def test_cleanup_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry when tag is removed.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, @@ -664,11 +664,11 @@ async def test_cleanup_device( async def test_cleanup_device_several_tags( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test removal from device registry when the last tag is removed.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "topic": "test-topic1", "device": {"identifiers": ["helloworld"]}, @@ -712,13 +712,13 @@ async def test_cleanup_device_several_tags( async def test_cleanup_device_with_entity_and_trigger_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry for device with tag, entity and trigger. Tag removed first, then trigger and entity. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, @@ -779,13 +779,13 @@ async def test_cleanup_device_with_entity_and_trigger_1( async def test_cleanup_device_with_entity2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test removal from device registry for device with tag, entity and trigger. Trigger and entity removed first, then tag. """ - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, @@ -846,11 +846,11 @@ async def test_cleanup_device_with_entity2( @pytest.mark.xfail(raises=MultipleInvalid) async def test_update_with_bad_config_not_breaks_discovery( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, tag_mock, ) -> None: """Test a bad update does not break discovery.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() config1 = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 10e9f0780d5..b96b82277b0 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -87,10 +87,10 @@ async def async_set_value( ], ) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the controlling state via topic.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -133,11 +133,11 @@ async def test_controlling_state_via_topic( ) async def test_controlling_validation_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the validation of a received state.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -203,11 +203,11 @@ async def test_controlling_validation_state_via_topic( ], ) async def test_attribute_validation_max_greater_then_min( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the validation of min and max configuration attributes.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize( @@ -226,11 +226,11 @@ async def test_attribute_validation_max_greater_then_min( ], ) async def test_attribute_validation_max_not_greater_then_max_state_length( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the max value of of max configuration attribute.""" with pytest.raises(AssertionError): - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() @pytest.mark.parametrize( @@ -248,10 +248,10 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( ], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending MQTT commands in optimistic mode.""" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -294,10 +294,10 @@ async def test_sending_mqtt_commands_and_optimistic( ], ) async def test_set_text_validation( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the initial state in optimistic mode.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -322,26 +322,26 @@ async def test_set_text_validation( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN + hass, mqtt_mock_entry, text.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -355,7 +355,7 @@ async def test_default_availability_payload( } await help_test_default_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, text.DOMAIN, config, True, @@ -365,7 +365,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -380,7 +380,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, text.DOMAIN, config, True, @@ -390,41 +390,41 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG, {} + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG, {} ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, text.DOMAIN, DEFAULT_CONFIG, @@ -433,13 +433,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, text.DOMAIN, DEFAULT_CONFIG, @@ -448,13 +448,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, text.DOMAIN, DEFAULT_CONFIG, @@ -485,15 +485,15 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one text per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, text.DOMAIN) async def test_discovery_removal_text( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered text entity.""" @@ -502,14 +502,12 @@ async def test_discovery_removal_text( ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, data - ) + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, text.DOMAIN, data) async def test_discovery_text_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered text entity.""" @@ -525,13 +523,13 @@ async def test_discovery_text_update( } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, text.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered update.""" @@ -541,7 +539,7 @@ async def test_discovery_update_unchanged_update( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, text.DOMAIN, data1, @@ -551,20 +549,20 @@ async def test_discovery_update_unchanged_update( async def test_discovery_update_text( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered text entity.""" config1 = {"name": "Beer", "command_topic": "cmd-topic1"} config2 = {"name": "Milk", "command_topic": "cmd-topic2"} await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, text.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_climate( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered text entity.""" @@ -574,7 +572,7 @@ async def test_discovery_update_unchanged_climate( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, text.DOMAIN, data1, @@ -585,7 +583,7 @@ async def test_discovery_update_unchanged_climate( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -596,70 +594,70 @@ async def test_discovery_broken( ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, text.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT text device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT text device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG ) async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG, None + hass, mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG, None ) @@ -677,7 +675,7 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, service: str, topic: str, @@ -691,7 +689,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, domain, config, @@ -721,7 +719,7 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, topic: str, value: str, attribute: str | None, @@ -730,7 +728,7 @@ async def test_encoding_subscribable_topics( """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, text.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][text.DOMAIN], topic, @@ -742,21 +740,21 @@ async def test_encoding_subscribable_topics( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = text.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = text.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index c4b197275a3..83868c3387c 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -26,10 +26,10 @@ def no_platforms(): @pytest.fixture(autouse=True) -async def setup_comp(hass: HomeAssistant, mqtt_mock_entry_no_yaml_config): +async def setup_comp(hass: HomeAssistant, mqtt_mock_entry): """Initialize components.""" mock_component(hass, "group") - return await mqtt_mock_entry_no_yaml_config() + return await mqtt_mock_entry() async def test_if_fires_on_topic_match(hass: HomeAssistant, calls) -> None: diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index bdd85768b85..8e2cdaf8eaa 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -63,30 +63,53 @@ def update_platform_only(): @pytest.mark.parametrize( - "hass_config", + ("hass_config", "device_class"), [ - { - mqtt.DOMAIN: { - update.DOMAIN: { - "state_topic": "test/installed-version", - "latest_version_topic": "test/latest-version", - "name": "Test Update", - "release_summary": "Test release summary", - "release_url": "https://example.com/release", - "title": "Test Update Title", - "entity_picture": "https://example.com/icon.png", + ( + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", + "name": "Test Update", + "release_summary": "Test release summary", + "release_url": "https://example.com/release", + "title": "Test Update Title", + "entity_picture": "https://example.com/icon.png", + "device_class": "firmware", + } } - } - } + }, + "firmware", + ), + ( + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", + "name": "Test Update", + "release_summary": "Test release summary", + "release_url": "https://example.com/release", + "title": "Test Update Title", + "entity_picture": "https://example.com/icon.png", + "device_class": None, + } + } + }, + None, + ), ], ) async def test_run_update_setup( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_class: str | None, ) -> None: """Test that it fetches the given payload.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") @@ -101,6 +124,7 @@ async def test_run_update_setup( assert state.attributes.get("release_url") == "https://example.com/release" assert state.attributes.get("title") == "Test Update Title" assert state.attributes.get("entity_picture") == "https://example.com/icon.png" + assert state.attributes.get("device_class") == device_class async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") @@ -131,12 +155,12 @@ async def test_run_update_setup( ], ) async def test_run_update_setup_float( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that it fetches the given payload when the version is parsable as a number.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, installed_version_topic, "1.9") async_fire_mqtt_message(hass, latest_version_topic, "1.9") @@ -179,12 +203,12 @@ async def test_run_update_setup_float( ], ) async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that it fetches the given payload with a template.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') @@ -227,12 +251,12 @@ async def test_value_template( ], ) async def test_value_template_float( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that it fetches the given payload with a template when the version is parsable as a number.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9"}') @@ -272,11 +296,11 @@ async def test_value_template_float( ], ) async def test_empty_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test an empty JSON payload.""" state_topic = "test/state-topic" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, "{}") @@ -300,11 +324,11 @@ async def test_empty_json_state_message( ], ) async def test_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test whether it fetches data from a JSON payload.""" state_topic = "test/state-topic" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message( hass, @@ -358,11 +382,11 @@ async def test_json_state_message( ], ) async def test_json_state_message_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test whether it fetches data from a JSON payload with template.""" state_topic = "test/state-topic" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') @@ -400,14 +424,14 @@ async def test_json_state_message_with_template( ], ) async def test_run_install_service( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test that install service works.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" command_topic = "test/install-command" - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mqtt_mock = await mqtt_mock_entry() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") @@ -429,69 +453,69 @@ async def test_run_install_service( @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN + hass, mqtt_mock_entry, update.DOMAIN ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, update.DOMAIN, DEFAULT_CONFIG, @@ -500,13 +524,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, update.DOMAIN, DEFAULT_CONFIG, @@ -515,13 +539,13 @@ async def test_update_with_json_attrs_bad_json( async def test_discovery_update_attr( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, update.DOMAIN, DEFAULT_CONFIG, @@ -552,27 +576,27 @@ async def test_discovery_update_attr( ], ) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one update per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN) + await help_test_unique_id(hass, mqtt_mock_entry, update.DOMAIN) async def test_discovery_removal_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered update.""" data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][update.DOMAIN]) await help_test_discovery_removal( - hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data + hass, mqtt_mock_entry, caplog, update.DOMAIN, data ) async def test_discovery_update_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered update.""" @@ -588,13 +612,13 @@ async def test_discovery_update_update( } await help_test_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, config1, config2 + hass, mqtt_mock_entry, caplog, update.DOMAIN, config1, config2 ) async def test_discovery_update_unchanged_update( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered update.""" @@ -604,7 +628,7 @@ async def test_discovery_update_unchanged_update( ) as discovery_update: await help_test_discovery_update_unchanged( hass, - mqtt_mock_entry_no_yaml_config, + mqtt_mock_entry, caplog, update.DOMAIN, data1, @@ -615,7 +639,7 @@ async def test_discovery_update_unchanged_update( @pytest.mark.no_fail_on_log_exception async def test_discovery_broken( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" @@ -623,74 +647,74 @@ async def test_discovery_broken( data2 = '{ "name": "Milk", "state_topic": "installed-topic", "latest_version_topic": "latest-topic" }' await help_test_discovery_broken( - hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data1, data2 + hass, mqtt_mock_entry, caplog, update.DOMAIN, data1, data2 ) async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT update device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT update device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, update.DOMAIN, DEFAULT_CONFIG ) @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry_no_yaml_config() + await mqtt_mock_entry() platform = update.DOMAIN assert hass.states.get(f"{platform}.test") async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_no_yaml_config, domain, config + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index fa9451ebf73..96577bd3fa4 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -1,12 +1,17 @@ """Test MQTT utils.""" +from collections.abc import Callable from random import getrandbits from unittest.mock import patch import pytest from homeassistant.components import mqtt -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState +from homeassistant.core import CoreState, HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient, MqttMockPahoClient @pytest.fixture(autouse=True) @@ -48,3 +53,163 @@ async def test_reading_non_exitisting_certificate_file() -> None: assert ( mqtt.util.migrate_certificate_file_to_content("/home/file_not_exists") is None ) + + +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_waiting_for_client_not_loaded( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test waiting for client while mqtt entry is not yet loaded.""" + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={"broker": "test-broker"}, + state=ConfigEntryState.NOT_LOADED, + ) + entry.add_to_hass(hass) + + unsubs: list[Callable[[], None]] = [] + + async def _async_just_in_time_subscribe() -> Callable[[], None]: + nonlocal unsub + assert await mqtt.async_wait_for_mqtt_client(hass) + # Awaiting a second time should work too and return True + assert await mqtt.async_wait_for_mqtt_client(hass) + unsubs.append(await mqtt.async_subscribe(hass, "test_topic", lambda msg: None)) + + # Simulate some integration waiting for the client to become available + hass.async_add_job(_async_just_in_time_subscribe) + hass.async_add_job(_async_just_in_time_subscribe) + hass.async_add_job(_async_just_in_time_subscribe) + hass.async_add_job(_async_just_in_time_subscribe) + + assert entry.state == ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_setup(entry.entry_id) + assert len(unsubs) == 4 + for unsub in unsubs: + unsub() + + +@patch("homeassistant.components.mqtt.PLATFORMS", []) +async def test_waiting_for_client_loaded( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, +) -> None: + """Test waiting for client where mqtt entry is loaded.""" + unsub: Callable[[], None] | None = None + + async def _async_just_in_time_subscribe() -> Callable[[], None]: + nonlocal unsub + assert await mqtt.async_wait_for_mqtt_client(hass) + unsub = await mqtt.async_subscribe(hass, "test_topic", lambda msg: None) + + entry = hass.config_entries.async_entries(mqtt.DATA_MQTT)[0] + assert entry.state == ConfigEntryState.LOADED + + await _async_just_in_time_subscribe() + + assert unsub is not None + unsub() + + +async def test_waiting_for_client_entry_fails( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test waiting for client where mqtt entry is failing.""" + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={"broker": "test-broker"}, + state=ConfigEntryState.NOT_LOADED, + ) + entry.add_to_hass(hass) + + async def _async_just_in_time_subscribe() -> Callable[[], None]: + assert not await mqtt.async_wait_for_mqtt_client(hass) + + hass.async_add_job(_async_just_in_time_subscribe) + assert entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.mqtt.async_setup_entry", + side_effect=Exception, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_waiting_for_client_setup_fails( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test waiting for client where mqtt entry is failing during setup.""" + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={"broker": "test-broker"}, + state=ConfigEntryState.NOT_LOADED, + ) + entry.add_to_hass(hass) + + async def _async_just_in_time_subscribe() -> Callable[[], None]: + assert not await mqtt.async_wait_for_mqtt_client(hass) + + hass.async_add_job(_async_just_in_time_subscribe) + assert entry.state == ConfigEntryState.NOT_LOADED + + # Simulate MQTT setup fails before the client would become available + mqtt_client_mock.connect.side_effect = Exception + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR + + +@patch("homeassistant.components.mqtt.util.AVAILABILITY_TIMEOUT", 0.01) +async def test_waiting_for_client_timeout( + hass: HomeAssistant, +) -> None: + """Test waiting for client with timeout.""" + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={"broker": "test-broker"}, + state=ConfigEntryState.NOT_LOADED, + ) + entry.add_to_hass(hass) + + assert entry.state == ConfigEntryState.NOT_LOADED + # returns False after timeout + assert not await mqtt.async_wait_for_mqtt_client(hass) + + +async def test_waiting_for_client_with_disabled_entry( + hass: HomeAssistant, +) -> None: + """Test waiting for client with timeout.""" + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={"broker": "test-broker"}, + state=ConfigEntryState.NOT_LOADED, + ) + entry.add_to_hass(hass) + + # Disable MQTT config entry + await hass.config_entries.async_set_disabled_by( + entry.entry_id, ConfigEntryDisabler.USER + ) + + assert entry.state == ConfigEntryState.NOT_LOADED + + # returns False because entry is disabled + assert not await mqtt.async_wait_for_mqtt_client(hass) diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index 2cc5299061b..8423ccd8da2 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -11,6 +11,8 @@ from homeassistant.components.device_tracker.legacy import ( DOMAIN as DT_DOMAIN, YAML_DEVICES, ) +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -39,6 +41,28 @@ async def setup_comp( os.remove(yaml_devices) +async def test_setup_fails_without_mqtt_being_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Ensure mqtt is started when we setup the component.""" + # Simulate MQTT is was removed + mqtt_entry = hass.config_entries.async_entries(MQTT_DOMAIN)[0] + await hass.config_entries.async_unload(mqtt_entry.entry_id) + await hass.config_entries.async_set_disabled_by( + mqtt_entry.entry_id, ConfigEntryDisabler.USER + ) + + dev_id = "zanzito" + topic = "location/zanzito" + + await async_setup_component( + hass, + DT_DOMAIN, + {DT_DOMAIN: {CONF_PLATFORM: "mqtt_json", "devices": {dev_id: topic}}}, + ) + assert "MQTT integration is not available" in caplog.text + + async def test_ensure_device_tracker_platform_validation(hass: HomeAssistant) -> None: """Test if platform validation was done.""" diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 999bcebd174..1d6b2980ab2 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -3,6 +3,8 @@ import datetime import json from unittest.mock import patch +import pytest + from homeassistant.components.mqtt import CONF_QOS, CONF_STATE_TOPIC, DEFAULT_QOS import homeassistant.components.sensor as sensor from homeassistant.const import ( @@ -56,6 +58,28 @@ async def assert_distance(hass, distance): assert state.attributes.get("distance") == distance +async def test_no_mqtt(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test no mqtt available.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + CONF_PLATFORM: "mqtt_room", + CONF_NAME: NAME, + CONF_DEVICE_ID: DEVICE_ID, + CONF_STATE_TOPIC: "room_presence", + CONF_QOS: DEFAULT_QOS, + CONF_TIMEOUT: 5, + } + }, + ) + await hass.async_block_till_done() + state = hass.states.get(SENSOR_STATE) + assert state is None + assert "MQTT integration is not available" in caplog.text + + async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the updating between rooms.""" assert await async_setup_component( diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index c5c91a97eea..cd228183c9e 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -96,6 +96,9 @@ async def test_setup_and_stop_waits_for_ha( mqtt_mock.async_publish.assert_not_called() +# We use xfail with this test because there is an unhandled exception +# in a background task in this test. +# The exception is raised by mqtt.async_publish. @pytest.mark.xfail() async def test_startup_no_mqtt( hass: HomeAssistant, caplog: pytest.LogCaptureFixture diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 9aafcae2482..78a96e148ce 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -34,7 +34,7 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} with patch( @@ -64,7 +64,7 @@ async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} with patch( diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 7aa77d6884d..dbd1c152d6b 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -19,9 +19,7 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test a successful setup entry.""" await init_integration(hass) - state = hass.states.get( - "sensor.nettigo_air_monitor_sds011_particulate_matter_2_5_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_sds011_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == "11.0" diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index ce3da0bae50..4f1b95ea206 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -265,9 +265,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_caqi" - state = hass.states.get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_10_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_pmsx003_pm10") assert state assert state.state == "10.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 @@ -277,15 +275,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_10_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p1" - state = hass.states.get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_2_5_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_pmsx003_pm2_5") assert state assert state.state == "11.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 @@ -295,15 +289,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_2_5_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p2" - state = hass.states.get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_1_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_pmsx003_pm1") assert state assert state.state == "6.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 @@ -313,15 +303,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_pmsx003_particulate_matter_1_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_pmsx003_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-pms_p0" - state = hass.states.get( - "sensor.nettigo_air_monitor_sds011_particulate_matter_10_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_sds011_pm10") assert state assert state.state == "18.6" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 @@ -331,9 +317,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_sds011_particulate_matter_10_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p1" @@ -372,9 +356,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_caqi_level" assert entry.translation_key == "sds011_caqi_level" - state = hass.states.get( - "sensor.nettigo_air_monitor_sds011_particulate_matter_2_5_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_sds011_pm2_5") assert state assert state.state == "11.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 @@ -384,9 +366,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_sds011_particulate_matter_2_5_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sds011_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011_p2" @@ -423,7 +403,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_caqi_level" assert entry.translation_key == "sps30_caqi_level" - state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_1_mm") + state = hass.states.get("sensor.nettigo_air_monitor_sps30_pm1") assert state assert state.state == "31.2" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 @@ -433,13 +413,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_sps30_particulate_matter_1_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm1") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p0" - state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_10_mm") + state = hass.states.get("sensor.nettigo_air_monitor_sps30_pm10") assert state assert state.state == "21.2" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 @@ -449,15 +427,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_sps30_particulate_matter_10_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm10") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p1" - state = hass.states.get( - "sensor.nettigo_air_monitor_sps30_particulate_matter_2_5_mm" - ) + state = hass.states.get("sensor.nettigo_air_monitor_sps30_pm2_5") assert state assert state.state == "34.3" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 @@ -467,13 +441,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get( - "sensor.nettigo_air_monitor_sps30_particulate_matter_2_5_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm2_5") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p2" - state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_4_mm") + state = hass.states.get("sensor.nettigo_air_monitor_sps30_pm4") assert state assert state.state == "24.7" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT @@ -483,9 +455,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get( - "sensor.nettigo_air_monitor_sps30_particulate_matter_4_mm" - ) + entry = registry.async_get("sensor.nettigo_air_monitor_sps30_pm4") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30_p4" diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 375dce4e723..a3f7dfcb9d2 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -81,6 +81,7 @@ async def fake_post_request_no_data(*args, **kwargs): async def simulate_webhook(hass, webhook_id, response): """Simulate a webhook event.""" request = MockRequest( + method="POST", content=bytes(json.dumps({**COMMON_RESPONSE, **response}), "utf-8"), mock_source="test", ) diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 24f0004835d..3ef09cae6c8 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -61,21 +61,27 @@ SETTINGS = Settings( youtube_restricted_mode=False, block_9gag=True, block_amazon=True, + block_bereal=True, block_blizzard=True, + block_chatgpt=True, block_dailymotion=True, block_discord=True, block_disneyplus=True, block_ebay=True, block_facebook=True, block_fortnite=True, + block_google_chat=True, + block_hbomax=True, block_hulu=True, block_imgur=True, block_instagram=True, block_leagueoflegends=True, + block_mastodon=True, block_messenger=True, block_minecraft=True, block_netflix=True, block_pinterest=True, + block_playstation_network=True, block_primevideo=True, block_reddit=True, block_roblox=True, @@ -98,9 +104,11 @@ SETTINGS = Settings( block_zoom=True, block_dating=True, block_gambling=True, + block_online_gaming=True, block_piracy=True, block_porn=True, block_social_networks=True, + block_video_streaming=True, ) diff --git a/tests/components/nextdns/fixtures/settings.json b/tests/components/nextdns/fixtures/settings.json index 0a88ccb31ab..57ed97cfb19 100644 --- a/tests/components/nextdns/fixtures/settings.json +++ b/tests/components/nextdns/fixtures/settings.json @@ -26,21 +26,27 @@ "youtube_restricted_mode": false, "block_9gag": true, "block_amazon": true, + "block_bereal": true, "block_blizzard": true, + "block_chatgpt": true, "block_dailymotion": true, "block_discord": true, "block_disneyplus": true, "block_ebay": true, "block_facebook": true, "block_fortnite": true, + "block_google_chat": true, + "block_hbomax": true, "block_hulu": true, "block_imgur": true, "block_instagram": true, "block_leagueoflegends": true, + "block_mastodon": true, "block_messenger": true, "block_minecraft": true, "block_netflix": true, "block_pinterest": true, + "block_playstation_network": true, "block_primevideo": true, "block_reddit": true, "block_roblox": true, @@ -63,7 +69,9 @@ "block_zoom": true, "block_dating": true, "block_gambling": true, + "block_online_gaming": true, "block_piracy": true, "block_porn": true, - "block_social_networks": true + "block_social_networks": true, + "block_video_streaming": true } diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 07591ed2bbf..b5d718b61aa 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -24,7 +24,7 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} with patch( diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 7739218d92c..ed0ea4e8620 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -8,7 +8,6 @@ from aiohttp.client_exceptions import ClientConnectorError from nextdns import ApiError import pytest -from homeassistant.components.nextdns.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -28,346 +27,12 @@ from . import SETTINGS, init_integration from tests.common import async_fire_time_changed -async def test_switch(hass: HomeAssistant) -> None: +async def test_switch( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test states of the switches.""" registry = er.async_get(hass) - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_9gag", - suggested_object_id="fake_profile_block_9gag", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_amazon", - suggested_object_id="fake_profile_block_amazon", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_blizzard", - suggested_object_id="fake_profile_block_blizzard", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_dailymotion", - suggested_object_id="fake_profile_block_dailymotion", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_discord", - suggested_object_id="fake_profile_block_discord", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_disneyplus", - suggested_object_id="fake_profile_block_disneyplus", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_ebay", - suggested_object_id="fake_profile_block_ebay", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_facebook", - suggested_object_id="fake_profile_block_facebook", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_fortnite", - suggested_object_id="fake_profile_block_fortnite", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_hulu", - suggested_object_id="fake_profile_block_hulu", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_imgur", - suggested_object_id="fake_profile_block_imgur", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_instagram", - suggested_object_id="fake_profile_block_instagram", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_leagueoflegends", - suggested_object_id="fake_profile_block_league_of_legends", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_messenger", - suggested_object_id="fake_profile_block_messenger", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_minecraft", - suggested_object_id="fake_profile_block_minecraft", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_netflix", - suggested_object_id="fake_profile_block_netflix", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_pinterest", - suggested_object_id="fake_profile_block_pinterest", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_primevideo", - suggested_object_id="fake_profile_block_primevideo", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_reddit", - suggested_object_id="fake_profile_block_reddit", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_roblox", - suggested_object_id="fake_profile_block_roblox", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_signal", - suggested_object_id="fake_profile_block_signal", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_skype", - suggested_object_id="fake_profile_block_skype", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_snapchat", - suggested_object_id="fake_profile_block_snapchat", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_spotify", - suggested_object_id="fake_profile_block_spotify", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_steam", - suggested_object_id="fake_profile_block_steam", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_telegram", - suggested_object_id="fake_profile_block_telegram", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_tiktok", - suggested_object_id="fake_profile_block_tiktok", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_tinder", - suggested_object_id="fake_profile_block_tinder", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_tumblr", - suggested_object_id="fake_profile_block_tumblr", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_twitch", - suggested_object_id="fake_profile_block_twitch", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_twitter", - suggested_object_id="fake_profile_block_twitter", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_vimeo", - suggested_object_id="fake_profile_block_vimeo", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_vk", - suggested_object_id="fake_profile_block_vk", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_whatsapp", - suggested_object_id="fake_profile_block_whatsapp", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_xboxlive", - suggested_object_id="fake_profile_block_xboxlive", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_youtube", - suggested_object_id="fake_profile_block_youtube", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_zoom", - suggested_object_id="fake_profile_block_zoom", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_dating", - suggested_object_id="fake_profile_block_dating", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_gambling", - suggested_object_id="fake_profile_block_gambling", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_piracy", - suggested_object_id="fake_profile_block_piracy", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_porn", - suggested_object_id="fake_profile_block_porn", - disabled_by=None, - ) - - registry.async_get_or_create( - SWITCH_DOMAIN, - DOMAIN, - "xyz12_block_social_networks", - suggested_object_id="fake_profile_block_social_networks", - disabled_by=None, - ) - await init_integration(hass) state = hass.states.get("switch.fake_profile_ai_driven_threat_detection") @@ -576,6 +241,14 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_amazon" + state = hass.states.get("switch.fake_profile_block_bereal") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_bereal") + assert entry + assert entry.unique_id == "xyz12_block_bereal" + state = hass.states.get("switch.fake_profile_block_blizzard") assert state assert state.state == STATE_ON @@ -584,6 +257,14 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_blizzard" + state = hass.states.get("switch.fake_profile_block_chatgpt") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_chatgpt") + assert entry + assert entry.unique_id == "xyz12_block_chatgpt" + state = hass.states.get("switch.fake_profile_block_dailymotion") assert state assert state.state == STATE_ON @@ -600,11 +281,11 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_discord" - state = hass.states.get("switch.fake_profile_block_disneyplus") + state = hass.states.get("switch.fake_profile_block_disney_plus") assert state assert state.state == STATE_ON - entry = registry.async_get("switch.fake_profile_block_disneyplus") + entry = registry.async_get("switch.fake_profile_block_disney_plus") assert entry assert entry.unique_id == "xyz12_block_disneyplus" @@ -632,6 +313,22 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_fortnite" + state = hass.states.get("switch.fake_profile_block_google_chat") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_google_chat") + assert entry + assert entry.unique_id == "xyz12_block_google_chat" + + state = hass.states.get("switch.fake_profile_block_hbo_max") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_hbo_max") + assert entry + assert entry.unique_id == "xyz12_block_hbomax" + state = hass.states.get("switch.fake_profile_block_hulu") assert state assert state.state == STATE_ON @@ -664,6 +361,14 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_leagueoflegends" + state = hass.states.get("switch.fake_profile_block_mastodon") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_mastodon") + assert entry + assert entry.unique_id == "xyz12_block_mastodon" + state = hass.states.get("switch.fake_profile_block_messenger") assert state assert state.state == STATE_ON @@ -696,11 +401,19 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_pinterest" - state = hass.states.get("switch.fake_profile_block_primevideo") + state = hass.states.get("switch.fake_profile_block_playstation_network") assert state assert state.state == STATE_ON - entry = registry.async_get("switch.fake_profile_block_primevideo") + entry = registry.async_get("switch.fake_profile_block_playstation_network") + assert entry + assert entry.unique_id == "xyz12_block_playstation_network" + + state = hass.states.get("switch.fake_profile_block_prime_video") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_prime_video") assert entry assert entry.unique_id == "xyz12_block_primevideo" @@ -832,11 +545,11 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_whatsapp" - state = hass.states.get("switch.fake_profile_block_xboxlive") + state = hass.states.get("switch.fake_profile_block_xbox_live") assert state assert state.state == STATE_ON - entry = registry.async_get("switch.fake_profile_block_xboxlive") + entry = registry.async_get("switch.fake_profile_block_xbox_live") assert entry assert entry.unique_id == "xyz12_block_xboxlive" @@ -872,6 +585,14 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_gambling" + state = hass.states.get("switch.fake_profile_block_online_gaming") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_online_gaming") + assert entry + assert entry.unique_id == "xyz12_block_online_gaming" + state = hass.states.get("switch.fake_profile_block_piracy") assert state assert state.state == STATE_ON @@ -896,6 +617,14 @@ async def test_switch(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "xyz12_block_social_networks" + state = hass.states.get("switch.fake_profile_block_video_streaming") + assert state + assert state.state == STATE_ON + + entry = registry.async_get("switch.fake_profile_block_video_streaming") + assert entry + assert entry.unique_id == "xyz12_block_video_streaming" + async def test_switch_on(hass: HomeAssistant) -> None: """Test the switch can be turned on.""" diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 7484e8a997f..75eeda70300 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -3,10 +3,13 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch +from aionotion.bridge.models import Bridge +from aionotion.sensor.models import Listener, Sensor import pytest from homeassistant.components.notion import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -24,17 +27,29 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="client") -def client_fixture(data_bridge, data_sensor, data_task): +def client_fixture(data_bridge, data_listener, data_sensor): """Define a fixture for an aionotion client.""" return Mock( - bridge=Mock(async_all=AsyncMock(return_value=data_bridge)), - sensor=Mock(async_all=AsyncMock(return_value=data_sensor)), - task=Mock(async_all=AsyncMock(return_value=data_task)), + bridge=Mock( + async_all=AsyncMock( + return_value=[Bridge.parse_obj(bridge) for bridge in data_bridge] + ) + ), + sensor=Mock( + async_all=AsyncMock( + return_value=[Sensor.parse_obj(sensor) for sensor in data_sensor] + ), + async_listeners=AsyncMock( + return_value=[ + Listener.parse_obj(listener) for listener in data_listener + ] + ), + ), ) @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config): +def config_entry_fixture(hass: HomeAssistant, config): """Define a config entry fixture.""" entry = MockConfigEntry(domain=DOMAIN, unique_id=TEST_USERNAME, data=config) entry.add_to_hass(hass) @@ -56,18 +71,18 @@ def data_bridge_fixture(): return json.loads(load_fixture("bridge_data.json", "notion")) +@pytest.fixture(name="data_listener", scope="package") +def data_listener_fixture(): + """Define listener data.""" + return json.loads(load_fixture("listener_data.json", "notion")) + + @pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" return json.loads(load_fixture("sensor_data.json", "notion")) -@pytest.fixture(name="data_task", scope="package") -def data_task_fixture(): - """Define task data.""" - return json.loads(load_fixture("task_data.json", "notion")) - - @pytest.fixture(name="get_client") def get_client_fixture(client): """Define a fixture to mock the async_get_client method.""" @@ -88,7 +103,7 @@ async def mock_aionotion_fixture(client): @pytest.fixture(name="setup_config_entry") -async def setup_config_entry_fixture(hass, config_entry, mock_aionotion): +async def setup_config_entry_fixture(hass: HomeAssistant, config_entry, mock_aionotion): """Define a fixture to set up notion.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/notion/fixtures/bridge_data.json b/tests/components/notion/fixtures/bridge_data.json index c865dd18bb3..008967ece86 100644 --- a/tests/components/notion/fixtures/bridge_data.json +++ b/tests/components/notion/fixtures/bridge_data.json @@ -1,26 +1,50 @@ [ { "id": 12345, - "name": null, + "name": "Bridge 1", "mode": "home", - "hardware_id": "0x1234567890abcdef", + "hardware_id": "0x0000000000000000", + "hardware_revision": 4, + "firmware_version": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0" + }, + "missing_at": null, + "created_at": "2019-06-27T00:18:44.337Z", + "updated_at": "2023-03-19T03:20:16.061Z", + "system_id": 11111, + "firmware": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0" + }, + "links": { + "system": 11111 + } + }, + { + "id": 67890, + "name": "Bridge 2", + "mode": "home", + "hardware_id": "0x0000000000000000", "hardware_revision": 4, "firmware_version": { "wifi": "0.121.0", "wifi_app": "3.3.0", - "silabs": "1.0.1" + "silabs": "1.1.2" }, "missing_at": null, "created_at": "2019-04-30T01:43:50.497Z", - "updated_at": "2019-04-30T01:44:43.749Z", - "system_id": 12345, + "updated_at": "2023-01-02T19:09:58.251Z", + "system_id": 11111, "firmware": { "wifi": "0.121.0", "wifi_app": "3.3.0", - "silabs": "1.0.1" + "silabs": "1.1.2" }, "links": { - "system": 12345 + "system": 11111 } } ] diff --git a/tests/components/notion/fixtures/listener_data.json b/tests/components/notion/fixtures/listener_data.json new file mode 100644 index 00000000000..bd49aab89db --- /dev/null +++ b/tests/components/notion/fixtures/listener_data.json @@ -0,0 +1,55 @@ +[ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "definition_id": 4, + "created_at": "2019-06-28T22:12:49.651Z", + "type": "sensor", + "model_version": "2.1", + "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": { + "trigger_value": "no_leak", + "data_received_at": "2022-03-20T08:00:29.763Z" + }, + "status_localized": { + "state": "No Leak", + "description": "Mar 20 at 2:00am" + }, + "insights": { + "primary": { + "origin": { + "type": "Sensor", + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, + "value": "no_leak", + "data_received_at": "2022-03-20T08:00:29.763Z" + } + }, + "configuration": {}, + "pro_monitoring_status": "eligible" + }, + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "definition_id": 7, + "created_at": "2019-07-10T22:40:48.847Z", + "type": "sensor", + "model_version": "3.1", + "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": { + "trigger_value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516Z" + }, + "status_localized": { + "state": "No Sound", + "description": "Jun 28 at 4:12pm" + }, + "insights": { + "primary": { + "origin": {}, + "value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516Z" + } + }, + "configuration": {}, + "pro_monitoring_status": "eligible" + } +] diff --git a/tests/components/notion/fixtures/sensor_data.json b/tests/components/notion/fixtures/sensor_data.json index e631f856207..e042daf6ddd 100644 --- a/tests/components/notion/fixtures/sensor_data.json +++ b/tests/components/notion/fixtures/sensor_data.json @@ -7,64 +7,28 @@ "email": "user@email.com" }, "bridge": { - "id": 12345, - "hardware_id": "0x1234567890abcdef" + "id": 67890, + "hardware_id": "0x0000000000000000" }, "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Bathroom Sensor", + "name": "Sensor 1", "location_id": 123456, "system_id": 12345, - "hardware_id": "0x1234567890abcdef", - "firmware_version": "1.1.2", + "hardware_id": "0x0000000000000000", "hardware_revision": 5, - "device_key": "0x1234567890abcdef", - "encryption_key": true, - "installed_at": "2019-04-30T01:57:34.443Z", - "calibrated_at": "2019-04-30T01:57:35.651Z", - "last_reported_at": "2019-04-30T02:20:04.821Z", - "missing_at": null, - "updated_at": "2019-04-30T01:57:36.129Z", - "created_at": "2019-04-30T01:56:45.932Z", - "signal_strength": 5, - "links": { - "location": 123456 - }, - "lqi": 0, - "rssi": -46, - "surface_type": null - }, - { - "id": 132462, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": { - "id": 12345, - "email": "user@email.com" - }, - "bridge": { - "id": 12345, - "hardware_id": "0x1234567890abcdef" - }, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Living Room Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x1234567890abcdef", "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": "0x1234567890abcdef", + "device_key": "0x0000000000000000", "encryption_key": true, - "installed_at": "2019-04-30T01:45:56.169Z", - "calibrated_at": "2019-04-30T01:46:06.256Z", - "last_reported_at": "2019-04-30T02:20:04.829Z", + "installed_at": "2019-06-28T22:12:51.209Z", + "calibrated_at": "2023-03-07T19:51:56.838Z", + "last_reported_at": "2023-04-19T18:09:40.479Z", "missing_at": null, - "updated_at": "2019-04-30T01:46:07.717Z", - "created_at": "2019-04-30T01:45:14.148Z", - "signal_strength": 5, - "links": { - "location": 123456 + "updated_at": "2023-03-28T13:33:33.801Z", + "created_at": "2019-06-28T22:12:20.256Z", + "signal_strength": 4, + "firmware": { + "status": "valid" }, - "lqi": 0, - "rssi": -30, "surface_type": null } ] diff --git a/tests/components/notion/fixtures/task_data.json b/tests/components/notion/fixtures/task_data.json deleted file mode 100644 index a56d734fb77..00000000000 --- a/tests/components/notion/fixtures/task_data.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "missing", - "sensor_data": [], - "status": { - "value": "not_missing", - "received_at": "2020-11-11T21:18:06.613Z" - }, - "created_at": "2020-11-11T21:18:06.613Z", - "updated_at": "2020-11-11T21:18:06.617Z", - "sensor_id": 525993, - "model_version": "2.0", - "configuration": {}, - "links": { - "sensor": 525993 - } - }, - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "leak", - "sensor_data": [], - "status": { - "insights": { - "primary": { - "from_state": null, - "to_state": "no_leak", - "data_received_at": "2020-11-11T21:19:13.755Z", - "origin": {} - } - } - }, - "created_at": "2020-11-11T21:19:13.755Z", - "updated_at": "2020-11-11T21:19:13.764Z", - "sensor_id": 525993, - "model_version": "2.1", - "configuration": {}, - "links": { - "sensor": 525993 - } - }, - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "temperature", - "sensor_data": [], - "status": { - "value": "20.991287231445312", - "received_at": "2021-01-27T15:18:49.996Z" - }, - "created_at": "2020-11-11T21:19:13.856Z", - "updated_at": "2020-11-11T21:19:13.865Z", - "sensor_id": 525993, - "model_version": "2.1", - "configuration": { - "lower": 15.56, - "upper": 29.44, - "offset": 0 - }, - "links": { - "sensor": 525993 - } - }, - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "low_battery", - "sensor_data": [], - "status": { - "insights": { - "primary": { - "from_state": null, - "to_state": "high", - "data_received_at": "2020-11-17T18:40:27.024Z", - "origin": {} - } - } - }, - "created_at": "2020-11-17T18:40:27.024Z", - "updated_at": "2020-11-17T18:40:27.033Z", - "sensor_id": 525993, - "model_version": "4.1", - "configuration": {}, - "links": { - "sensor": 525993 - } - } -] diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index bc28a98b875..7062778e812 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Notion diagnostics.""" from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.notion import DOMAIN from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -17,7 +18,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, - "domain": "notion", + "domain": DOMAIN, "title": REDACTED, "data": {"username": REDACTED, "password": REDACTED}, "options": {}, @@ -28,106 +29,107 @@ async def test_entry_diagnostics( "disabled_by": None, }, "data": { - "bridges": { - "12345": { + "bridges": [ + { "id": 12345, - "name": None, + "name": "Bridge 1", "mode": "home", "hardware_id": REDACTED, "hardware_revision": 4, "firmware_version": { + "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", - "silabs": "1.0.1", }, "missing_at": None, - "created_at": "2019-04-30T01:43:50.497Z", - "updated_at": "2019-04-30T01:44:43.749Z", - "system_id": 12345, + "created_at": "2019-06-27T00:18:44.337000+00:00", + "updated_at": "2023-03-19T03:20:16.061000+00:00", + "system_id": 11111, "firmware": { + "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", - "silabs": "1.0.1", }, - "links": {"system": 12345}, + "links": {"system": 11111}, + }, + { + "id": 67890, + "name": "Bridge 2", + "mode": "home", + "hardware_id": REDACTED, + "hardware_revision": 4, + "firmware_version": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0", + }, + "missing_at": None, + "created_at": "2019-04-30T01:43:50.497000+00:00", + "updated_at": "2023-01-02T19:09:58.251000+00:00", + "system_id": 11111, + "firmware": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0", + }, + "links": {"system": 11111}, + }, + ], + "listeners": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "listener_kind": { + "__type": "", + "repr": "", + }, + "created_at": "2019-07-10T22:40:48.847000+00:00", + "device_type": "sensor", + "model_version": "3.1", + "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": { + "trigger_value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516000+00:00", + }, + "status_localized": { + "state": "No Sound", + "description": "Jun 28 at 4:12pm", + }, + "insights": { + "primary": { + "origin": {"type": None, "id": None}, + "value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516000+00:00", + } + }, + "configuration": {}, + "pro_monitoring_status": "eligible", } - }, - "sensors": { - "123456": { + ], + "sensors": [ + { "id": 123456, "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": REDACTED}, + "bridge": {"id": 67890, "hardware_id": REDACTED}, "last_bridge_hardware_id": REDACTED, - "name": "Bathroom Sensor", + "name": "Sensor 1", "location_id": 123456, "system_id": 12345, "hardware_id": REDACTED, - "firmware_version": "1.1.2", "hardware_revision": 5, + "firmware_version": "1.1.2", "device_key": REDACTED, "encryption_key": True, - "installed_at": "2019-04-30T01:57:34.443Z", - "calibrated_at": "2019-04-30T01:57:35.651Z", - "last_reported_at": "2019-04-30T02:20:04.821Z", + "installed_at": "2019-06-28T22:12:51.209000+00:00", + "calibrated_at": "2023-03-07T19:51:56.838000+00:00", + "last_reported_at": "2023-04-19T18:09:40.479000+00:00", "missing_at": None, - "updated_at": "2019-04-30T01:57:36.129Z", - "created_at": "2019-04-30T01:56:45.932Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -46, + "updated_at": "2023-03-28T13:33:33.801000+00:00", + "created_at": "2019-06-28T22:12:20.256000+00:00", + "signal_strength": 4, + "firmware": {"status": "valid"}, "surface_type": None, - }, - "132462": { - "id": 132462, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": REDACTED}, - "last_bridge_hardware_id": REDACTED, - "name": "Living Room Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": REDACTED, - "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": REDACTED, - "encryption_key": True, - "installed_at": "2019-04-30T01:45:56.169Z", - "calibrated_at": "2019-04-30T01:46:06.256Z", - "last_reported_at": "2019-04-30T02:20:04.829Z", - "missing_at": None, - "updated_at": "2019-04-30T01:46:07.717Z", - "created_at": "2019-04-30T01:45:14.148Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -30, - "surface_type": None, - }, - }, - "tasks": { - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "low_battery", - "sensor_data": [], - "status": { - "insights": { - "primary": { - "from_state": None, - "to_state": "high", - "data_received_at": "2020-11-17T18:40:27.024Z", - "origin": {}, - } - } - }, - "created_at": "2020-11-17T18:40:27.024Z", - "updated_at": "2020-11-17T18:40:27.033Z", - "sensor_id": 525993, - "model_version": "4.1", - "configuration": {}, - "links": {"sensor": 525993}, } - }, + ], }, } diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 1d1f7c506e6..d996a67f93b 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -18,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} ) @@ -27,7 +28,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) > 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py new file mode 100644 index 00000000000..0664b0de5c8 --- /dev/null +++ b/tests/components/nut/test_device_action.py @@ -0,0 +1,229 @@ +"""The tests for Network UPS Tools (NUT) device actions.""" +from unittest.mock import MagicMock + +from pynut2.nut2 import PyNUTError +import pytest + +from homeassistant.components import automation, device_automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.nut import DOMAIN +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .util import async_init_integration + +from tests.common import assert_lists_same, async_get_device_automations + + +async def test_get_all_actions_for_specified_user( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we get all the expected actions from a nut if user is specified.""" + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + expected_actions = [ + { + "domain": DOMAIN, + "type": action.replace(".", "_"), + "device_id": device_entry.id, + "metadata": {}, + } + for action in INTEGRATION_SUPPORTED_COMMANDS + ] + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert_lists_same(actions, expected_actions) + + +async def test_no_actions_for_anonymous_user( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we get no actions if user is not specified.""" + list_commands_return_value = {"some action": "some description"} + + await async_init_integration( + hass, + username=None, + password=None, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + + assert len(actions) == 0 + + +async def test_no_actions_invalid_device( + hass: HomeAssistant, +) -> None: + """Test we get no actions for an invalid device.""" + list_commands_return_value = {"beeper.enable": None} + await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + + device_id = "invalid_device_id" + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + actions = await platform.async_get_actions(hass, device_id) + + assert len(actions) == 0 + + +async def test_list_commands_exception( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test there are no actions if list_commands raises exception.""" + await async_init_integration( + hass, list_vars={"ups.status": "OL"}, list_commands_side_effect=PyNUTError + ) + + device_entry = next(device for device in device_registry.devices.values()) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == 0 + + +async def test_unsupported_command( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test unsupported command is excluded.""" + + list_commands_return_value = { + "beeper.enable": None, + "device.something": "Does something unsupported", + } + await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) + assert len(actions) == 1 + + +async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) -> None: + """Test actions are executed.""" + + list_commands_return_value = { + "beeper.enable": None, + "beeper.disable": None, + } + run_command = MagicMock() + await async_init_integration( + hass, + list_ups={"someUps": "Some UPS"}, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_some_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "beeper_enable", + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_another_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "beeper_disable", + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_some_event") + await hass.async_block_till_done() + run_command.assert_called_with("someUps", "beeper.enable") + + hass.bus.async_fire("test_another_event") + await hass.async_block_till_done() + run_command.assert_called_with("someUps", "beeper.disable") + + +async def test_rund_command_exception( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logged error if run command raises exception.""" + + list_commands_return_value = {"beeper.enable": None} + error_message = "Something wrong happened" + run_command = MagicMock(side_effect=PyNUTError(error_message)) + await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_some_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "beeper_enable", + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_some_event") + await hass.async_block_till_done() + + assert error_message in caplog.text diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index df8b78be7bd..a0fadf47f19 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -4,28 +4,61 @@ import json from unittest.mock import MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -def _get_mock_pynutclient(list_vars=None, list_ups=None): +def _get_mock_pynutclient( + list_vars=None, + list_ups=None, + list_commands_return_value=None, + list_commands_side_effect=None, + run_command=None, +): pynutclient = MagicMock() type(pynutclient).list_ups = MagicMock(return_value=list_ups) type(pynutclient).list_vars = MagicMock(return_value=list_vars) + if list_commands_return_value is None: + list_commands_return_value = {} + type(pynutclient).list_commands = MagicMock( + return_value=list_commands_return_value, side_effect=list_commands_side_effect + ) + if run_command is None: + run_command = MagicMock() + type(pynutclient).run_command = run_command return pynutclient async def async_init_integration( - hass: HomeAssistant, ups_fixture: str + hass: HomeAssistant, + ups_fixture: str = None, + username: str = "mock", + password: str = "mock", + list_ups: dict[str, str] = None, + list_vars: dict[str, str] = None, + list_commands_return_value: dict[str, str] = None, + list_commands_side_effect=None, + run_command: MagicMock = None, ) -> MockConfigEntry: - """Set up the nexia integration in Home Assistant.""" + """Set up the nut integration in Home Assistant.""" - ups_fixture = f"nut/{ups_fixture}.json" - list_vars = json.loads(load_fixture(ups_fixture)) + if list_ups is None: + list_ups = {"ups1": "UPS 1"} - mock_pynut = _get_mock_pynutclient(list_ups={"ups1": "UPS 1"}, list_vars=list_vars) + if ups_fixture is not None: + ups_fixture = f"nut/{ups_fixture}.json" + if list_vars is None: + list_vars = json.loads(load_fixture(ups_fixture)) + + mock_pynut = _get_mock_pynutclient( + list_ups=list_ups, + list_vars=list_vars, + list_commands_return_value=list_commands_return_value, + list_commands_side_effect=list_commands_side_effect, + run_command=run_command, + ) with patch( "homeassistant.components.nut.PyNUTClient", @@ -33,7 +66,12 @@ async def async_init_integration( ): entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock"}, + data={ + CONF_HOST: "mock", + CONF_PASSWORD: password, + CONF_PORT: "mock", + CONF_USERNAME: username, + }, ) entry.add_to_hass(hass) diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py index 36d0f58fe4f..183f07aa00f 100644 --- a/tests/components/obihai/__init__.py +++ b/tests/components/obihai/__init__.py @@ -1,6 +1,6 @@ """Tests for the Obihai Integration.""" - +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME USER_INPUT = { @@ -8,3 +8,27 @@ USER_INPUT = { CONF_PASSWORD: "admin", CONF_USERNAME: "admin", } + +DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( + hostname="obi200", + ip="192.168.1.100", + macaddress="9CADEF000000", +) + + +class MockPyObihai: + """Mock PyObihai: Returns simulated PyObihai data.""" + + def get_device_mac(self): + """Mock PyObihai.get_device_mac, return simulated MAC address.""" + + return DHCP_SERVICE_INFO.macaddress + + +def get_schema_suggestion(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"] diff --git a/tests/components/obihai/conftest.py b/tests/components/obihai/conftest.py index 64e4d4b1a30..751f41f315a 100644 --- a/tests/components/obihai/conftest.py +++ b/tests/components/obihai/conftest.py @@ -1,6 +1,7 @@ """Define test fixtures for Obihai.""" from collections.abc import Generator +from socket import gaierror from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +10,19 @@ import pytest @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" + with patch( "homeassistant.components.obihai.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_gaierror() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + + with patch( + "homeassistant.components.obihai.config_flow.gethostbyname", + side_effect=gaierror(), + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 234f1d59967..1743b81a0e9 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -1,14 +1,17 @@ """Test the Obihai config flow.""" + +from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries from homeassistant.components.obihai.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import USER_INPUT +from . import DHCP_SERVICE_INFO, USER_INPUT, MockPyObihai, get_schema_suggestion VALIDATE_AUTH_PATCH = "homeassistant.components.obihai.config_flow.validate_auth" @@ -25,7 +28,7 @@ async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["step_id"] == "user" assert result["errors"] == {} - with patch("pyobihai.PyObihai.check_account"): + with patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()): result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -40,7 +43,7 @@ async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_auth_failure(hass: HomeAssistant) -> None: - """Test we get the authentication error.""" + """Test we get the authentication error for user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -52,6 +55,24 @@ async def test_auth_failure(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "invalid_auth" + + +async def test_connect_failure(hass: HomeAssistant, mock_gaierror: Generator) -> None: + """Test we get the connection error for user flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" @@ -59,7 +80,8 @@ async def test_auth_failure(hass: HomeAssistant) -> None: async def test_yaml_import(hass: HomeAssistant) -> None: """Test we get the YAML imported.""" - with patch(VALIDATE_AUTH_PATCH, return_value=True): + + with patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -71,8 +93,9 @@ async def test_yaml_import(hass: HomeAssistant) -> None: assert "errors" not in result -async def test_yaml_import_fail(hass: HomeAssistant) -> None: +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, @@ -81,6 +104,97 @@ async def test_yaml_import_fail(hass: HomeAssistant) -> None: ) 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.""" + + with patch( + VALIDATE_AUTH_PATCH, + return_value=MockPyObihai(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + flows = hass.config_entries.flow.async_progress() + assert result["type"] == FlowResultType.FORM + assert len(flows) == 1 + assert ( + get_schema_suggestion(result["data_schema"].schema, CONF_USERNAME) + == USER_INPUT[CONF_USERNAME] + ) + assert ( + get_schema_suggestion(result["data_schema"].schema, CONF_PASSWORD) + == USER_INPUT[CONF_PASSWORD] + ) + assert ( + get_schema_suggestion(result["data_schema"].schema, CONF_HOST) + == DHCP_SERVICE_INFO.ip + ) + assert flows[0].get("context", {}).get("source") == config_entries.SOURCE_DHCP + + # Verify we get dropped into the normal user flow with non-default credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_dhcp_flow_auth_failure(hass: HomeAssistant) -> None: + """Test that DHCP fails if creds aren't default.""" + + with patch( + VALIDATE_AUTH_PATCH, + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["step_id"] == "dhcp_confirm" + assert get_schema_suggestion(result["data_schema"].schema, CONF_USERNAME) == "" + assert get_schema_suggestion(result["data_schema"].schema, CONF_PASSWORD) == "" + assert ( + get_schema_suggestion(result["data_schema"].schema, CONF_HOST) + == DHCP_SERVICE_INFO.ip + ) + + # Verify we get dropped into the normal user flow with non-default credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: DHCP_SERVICE_INFO.ip, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + assert result["errors"]["base"] == "invalid_auth" + assert result["step_id"] == "user" diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..702196d4574 --- /dev/null +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1422 @@ +# serializer version: 1 +# name: test_binary_sensors[00.111111111111] + list([ + ]) +# --- +# name: test_binary_sensors[00.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[00.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[05.111111111111] + 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( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[05.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[05.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[10.111111111111] + 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( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[10.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[10.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[12.111111111111] + 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( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[12.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_a', + 'unique_id': '/12.111111111111/sensed.A', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_b', + 'unique_id': '/12.111111111111/sensed.B', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[12.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.A', + 'friendly_name': '12.111111111111 Sensed A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.B', + 'friendly_name': '12.111111111111 Sensed B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[1D.111111111111] + 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( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[1D.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[1D.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[1F.111111111111] + 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( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- +# name: test_binary_sensors[1F.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[1F.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[22.111111111111] + 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( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[22.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[22.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[26.111111111111] + 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( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[26.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[26.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[28.111111111111] + 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( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[28.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[28.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[28.222222222222] + 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( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[28.222222222222].1 + list([ + ]) +# --- +# name: test_binary_sensors[28.222222222222].2 + list([ + ]) +# --- +# name: test_binary_sensors[28.222222222223] + 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( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[28.222222222223].1 + list([ + ]) +# --- +# name: test_binary_sensors[28.222222222223].2 + list([ + ]) +# --- +# name: test_binary_sensors[29.111111111111] + 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( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[29.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_0', + 'unique_id': '/29.111111111111/sensed.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_1', + 'unique_id': '/29.111111111111/sensed.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_2', + 'unique_id': '/29.111111111111/sensed.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_3', + 'unique_id': '/29.111111111111/sensed.3', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 4', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_4', + 'unique_id': '/29.111111111111/sensed.4', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 5', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_5', + 'unique_id': '/29.111111111111/sensed.5', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 6', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_6', + 'unique_id': '/29.111111111111/sensed.6', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 7', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_7', + 'unique_id': '/29.111111111111/sensed.7', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[29.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.0', + 'friendly_name': '29.111111111111 Sensed 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.1', + 'friendly_name': '29.111111111111 Sensed 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.2', + 'friendly_name': '29.111111111111 Sensed 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.3', + 'friendly_name': '29.111111111111 Sensed 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.4', + 'friendly_name': '29.111111111111 Sensed 4', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.5', + 'friendly_name': '29.111111111111 Sensed 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.6', + 'friendly_name': '29.111111111111 Sensed 6', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.7', + 'friendly_name': '29.111111111111 Sensed 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[30.111111111111] + 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( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[30.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[30.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[3A.111111111111] + 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( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[3A.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_a', + 'unique_id': '/3A.111111111111/sensed.A', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'sensed_b', + 'unique_id': '/3A.111111111111/sensed.B', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[3A.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.A', + 'friendly_name': '3A.111111111111 Sensed A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.B', + 'friendly_name': '3A.111111111111 Sensed B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[3B.111111111111] + 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( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[3B.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[3B.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[42.111111111111] + 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( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[42.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[42.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[7E.111111111111] + 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( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[7E.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[7E.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[7E.222222222222] + 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( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[7E.222222222222].1 + list([ + ]) +# --- +# name: test_binary_sensors[7E.222222222222].2 + list([ + ]) +# --- +# name: test_binary_sensors[EF.111111111111] + 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( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[EF.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[EF.111111111111].2 + list([ + ]) +# --- +# name: test_binary_sensors[EF.111111111112] + 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( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[EF.111111111112].1 + list([ + ]) +# --- +# name: test_binary_sensors[EF.111111111112].2 + list([ + ]) +# --- +# name: test_binary_sensors[EF.111111111113] + 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( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[EF.111111111113].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_short_0', + 'unique_id': '/EF.111111111113/hub/short.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_short_1', + 'unique_id': '/EF.111111111113/hub/short.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_short_2', + 'unique_id': '/EF.111111111113/hub/short.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_short_3', + 'unique_id': '/EF.111111111113/hub/short.3', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[EF.111111111113].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.0', + 'friendly_name': 'EF.111111111113 Hub short on branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.1', + 'friendly_name': 'EF.111111111113 Hub short on branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.2', + 'friendly_name': 'EF.111111111113 Hub short on branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.3', + 'friendly_name': 'EF.111111111113 Hub short on branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6c18c1ec652 --- /dev/null +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -0,0 +1,2619 @@ +# serializer version: 1 +# name: test_sensors[00.111111111111] + list([ + ]) +# --- +# name: test_sensors[00.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[00.111111111111].2 + list([ + ]) +# --- +# name: test_sensors[05.111111111111] + 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( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[05.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[05.111111111111].2 + list([ + ]) +# --- +# name: test_sensors[10.111111111111] + 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( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[10.111111111111].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.10_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/10.111111111111/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[10.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/10.111111111111/temperature', + 'friendly_name': '10.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '25.1', + }), + ]) +# --- +# name: test_sensors[12.111111111111] + 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( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[12.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/12.111111111111/TAI8570/temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '/12.111111111111/TAI8570/pressure', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[12.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/12.111111111111/TAI8570/temperature', + 'friendly_name': '12.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.12_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '25.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/12.111111111111/TAI8570/pressure', + 'friendly_name': '12.111111111111 Pressure', + 'raw_value': 1025.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.12_111111111111_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1025.1', + }), + ]) +# --- +# name: test_sensors[1D.111111111111] + 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( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[1D.111111111111].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.1d_111111111111_counter_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'counter_a', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': 'count', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'counter_b', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': 'count', + }), + ]) +# --- +# name: test_sensors[1D.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + 'unit_of_measurement': 'count', + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_updated': , + 'state': '251123', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + 'unit_of_measurement': 'count', + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_updated': , + 'state': '248125', + }), + ]) +# --- +# name: test_sensors[1F.111111111111] + 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( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- +# name: test_sensors[1F.111111111111].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.1d_111111111111_counter_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'counter_a', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': 'count', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'counter_b', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': 'count', + }), + ]) +# --- +# name: test_sensors[1F.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1F.111111111111/main/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + 'unit_of_measurement': 'count', + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_updated': , + 'state': '251123', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1F.111111111111/main/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + 'unit_of_measurement': 'count', + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_updated': , + 'state': '248125', + }), + ]) +# --- +# name: test_sensors[22.111111111111] + 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( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[22.111111111111].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.22_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/22.111111111111/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[22.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/22.111111111111/temperature', + 'friendly_name': '22.111111111111 Temperature', + 'raw_value': None, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.22_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensors[26.111111111111] + 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( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[26.111111111111].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.26_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/26.111111111111/temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '/26.111111111111/humidity', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH3600 humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/26.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH4000 humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/26.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH5030 humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/26.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HTM1735 humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/26.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/26.111111111111/VAD', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/26.111111111111/VDD', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/26.111111111111/vis', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[26.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/26.111111111111/temperature', + 'friendly_name': '26.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '25.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/humidity', + 'friendly_name': '26.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_humidity', + 'last_changed': , + 'last_updated': , + 'state': '72.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH3600/humidity', + 'friendly_name': '26.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'last_changed': , + 'last_updated': , + 'state': '73.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH4000/humidity', + 'friendly_name': '26.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'last_changed': , + 'last_updated': , + 'state': '74.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH5030/humidity', + 'friendly_name': '26.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'last_changed': , + 'last_updated': , + 'state': '75.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HTM1735/humidity', + 'friendly_name': '26.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/26.111111111111/B1-R1-A/pressure', + 'friendly_name': '26.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_pressure', + 'last_changed': , + 'last_updated': , + 'state': '969.3', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/26.111111111111/S3-R1-A/illuminance', + 'friendly_name': '26.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_illuminance', + 'last_changed': , + 'last_updated': , + 'state': '65.9', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VAD', + 'friendly_name': '26.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'last_changed': , + 'last_updated': , + 'state': '3.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VDD', + 'friendly_name': '26.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'last_changed': , + 'last_updated': , + 'state': '4.7', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/vis', + 'friendly_name': '26.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_updated': , + 'state': '0.1', + }), + ]) +# --- +# name: test_sensors[28.111111111111] + 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( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[28.111111111111].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.28_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/28.111111111111/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[28.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.111111111111/temperature', + 'friendly_name': '28.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + ]) +# --- +# name: test_sensors[28.222222222222] + 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( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[28.222222222222].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.28_222222222222_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/28.222222222222/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[28.222222222222].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222222/temperature9', + 'friendly_name': '28.222222222222 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222222_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + ]) +# --- +# name: test_sensors[28.222222222223] + 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( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[28.222222222223].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.28_222222222223_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/28.222222222223/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[28.222222222223].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222223/temperature', + 'friendly_name': '28.222222222223 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222223_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + ]) +# --- +# name: test_sensors[29.111111111111] + 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( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[29.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[29.111111111111].2 + list([ + ]) +# --- +# name: test_sensors[30.111111111111] + 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( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[30.111111111111].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.30_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/30.111111111111/temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermocouple K temperature', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'thermocouple_temperature_k', + 'unique_id': '/30.111111111111/typeX/temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': '/30.111111111111/volt', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage gradient', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'voltage_vis_gradient', + 'unique_id': '/30.111111111111/vis', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[30.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/temperature', + 'friendly_name': '30.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/typeK/temperature', + 'friendly_name': '30.111111111111 Thermocouple K temperature', + 'raw_value': 173.7563, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'last_changed': , + 'last_updated': , + 'state': '173.8', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/volt', + 'friendly_name': '30.111111111111 Voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_voltage', + 'last_changed': , + 'last_updated': , + 'state': '3.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/vis', + 'friendly_name': '30.111111111111 VIS voltage gradient', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'last_changed': , + 'last_updated': , + 'state': '0.1', + }), + ]) +# --- +# name: test_sensors[3A.111111111111] + 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( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[3A.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[3A.111111111111].2 + list([ + ]) +# --- +# name: test_sensors[3B.111111111111] + 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( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[3B.111111111111].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.3b_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/3B.111111111111/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[3B.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/3B.111111111111/temperature', + 'friendly_name': '3B.111111111111 Temperature', + 'raw_value': 28.243, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3b_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '28.2', + }), + ]) +# --- +# name: test_sensors[42.111111111111] + 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( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[42.111111111111].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.42_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/42.111111111111/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[42.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/42.111111111111/temperature', + 'friendly_name': '42.111111111111 Temperature', + 'raw_value': 29.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.42_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '29.1', + }), + ]) +# --- +# name: test_sensors[7E.111111111111] + 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( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[7E.111111111111].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.7e_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/7E.111111111111/EDS0068/temperature', + '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.7e_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '/7E.111111111111/EDS0068/pressure', + '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.7e_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': '/7E.111111111111/EDS0068/light', + 'unit_of_measurement': 'lx', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '/7E.111111111111/EDS0068/humidity', + 'unit_of_measurement': '%', + }), + ]) +# --- +# name: test_sensors[7E.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.111111111111/EDS0068/temperature', + 'friendly_name': '7E.111111111111 Temperature', + 'raw_value': 13.9375, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '13.9', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.111111111111/EDS0068/pressure', + 'friendly_name': '7E.111111111111 Pressure', + 'raw_value': 1012.21, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1012.2', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/7E.111111111111/EDS0068/light', + 'friendly_name': '7E.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'last_changed': , + 'last_updated': , + 'state': '65.9', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/7E.111111111111/EDS0068/humidity', + 'friendly_name': '7E.111111111111 Humidity', + 'raw_value': 41.375, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_humidity', + 'last_changed': , + 'last_updated': , + 'state': '41.4', + }), + ]) +# --- +# name: test_sensors[7E.222222222222] + 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( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[7E.222222222222].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.7e_222222222222_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/7E.222222222222/EDS0066/temperature', + '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.7e_222222222222_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[7E.222222222222].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.222222222222/EDS0066/temperature', + 'friendly_name': '7E.222222222222 Temperature', + 'raw_value': 13.9375, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_222222222222_temperature', + 'last_changed': , + 'last_updated': , + 'state': '13.9', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.222222222222/EDS0066/pressure', + 'friendly_name': '7E.222222222222 Pressure', + 'raw_value': 1012.21, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_222222222222_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1012.2', + }), + ]) +# --- +# name: test_sensors[EF.111111111111] + 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( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[EF.111111111111].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.ef_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + '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.ef_111111111111_raw_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw humidity', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'humidity_raw', + 'unique_id': '/EF.111111111111/humidity/humidity_raw', + '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.ef_111111111111_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': 'onewire', + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '/EF.111111111111/humidity/temperature', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[EF.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_corrected', + 'friendly_name': 'EF.111111111111 Humidity', + 'raw_value': 67.745, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_humidity', + 'last_changed': , + 'last_updated': , + 'state': '67.7', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_raw', + 'friendly_name': 'EF.111111111111 Raw humidity', + 'raw_value': 65.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'last_changed': , + 'last_updated': , + 'state': '65.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/EF.111111111111/humidity/temperature', + 'friendly_name': 'EF.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_temperature', + 'last_changed': , + 'last_updated': , + 'state': '25.1', + }), + ]) +# --- +# name: test_sensors[EF.111111111112] + 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( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[EF.111111111112].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.ef_111111111112_wetness_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'wetness_0', + 'unique_id': '/EF.111111111112/moisture/sensor.0', + '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.ef_111111111112_wetness_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'wetness_1', + 'unique_id': '/EF.111111111112/moisture/sensor.1', + '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.ef_111111111112_moisture_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_2', + 'unique_id': '/EF.111111111112/moisture/sensor.2', + '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.ef_111111111112_moisture_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_3', + 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensors[EF.111111111112].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.0', + 'friendly_name': 'EF.111111111112 Wetness 0', + 'raw_value': 41.745, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'last_changed': , + 'last_updated': , + 'state': '41.7', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.1', + 'friendly_name': 'EF.111111111112 Wetness 1', + 'raw_value': 42.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'last_changed': , + 'last_updated': , + 'state': '42.5', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.2', + 'friendly_name': 'EF.111111111112 Moisture 2', + 'raw_value': 43.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'last_changed': , + 'last_updated': , + 'state': '43.1', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.3', + 'friendly_name': 'EF.111111111112 Moisture 3', + 'raw_value': 44.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'last_changed': , + 'last_updated': , + 'state': '44.1', + }), + ]) +# --- +# name: test_sensors[EF.111111111113] + 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( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[EF.111111111113].1 + list([ + ]) +# --- +# name: test_sensors[EF.111111111113].2 + list([ + ]) +# --- diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr new file mode 100644 index 00000000000..55ea7be1fa6 --- /dev/null +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -0,0 +1,2218 @@ +# serializer version: 1 +# name: test_switches[00.111111111111] + list([ + ]) +# --- +# name: test_switches[00.111111111111].1 + list([ + ]) +# --- +# name: test_switches[00.111111111111].2 + list([ + ]) +# --- +# name: test_switches[05.111111111111] + 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( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[05.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio', + 'unique_id': '/05.111111111111/PIO', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[05.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/PIO', + 'friendly_name': '05.111111111111 Programmed input-output', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- +# name: test_switches[10.111111111111] + 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( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[10.111111111111].1 + list([ + ]) +# --- +# name: test_switches[10.111111111111].2 + list([ + ]) +# --- +# name: test_switches[12.111111111111] + 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( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[12.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_a', + 'unique_id': '/12.111111111111/PIO.A', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_b', + 'unique_id': '/12.111111111111/PIO.B', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_a', + 'unique_id': '/12.111111111111/latch.A', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_b', + 'unique_id': '/12.111111111111/latch.B', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[12.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.A', + 'friendly_name': '12.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.B', + 'friendly_name': '12.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.A', + 'friendly_name': '12.111111111111 Latch A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_a', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.B', + 'friendly_name': '12.111111111111 Latch B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_b', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_switches[1D.111111111111] + 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( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[1D.111111111111].1 + list([ + ]) +# --- +# name: test_switches[1D.111111111111].2 + list([ + ]) +# --- +# name: test_switches[1F.111111111111] + 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( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- +# name: test_switches[1F.111111111111].1 + list([ + ]) +# --- +# name: test_switches[1F.111111111111].2 + list([ + ]) +# --- +# name: test_switches[22.111111111111] + 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( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[22.111111111111].1 + list([ + ]) +# --- +# name: test_switches[22.111111111111].2 + list([ + ]) +# --- +# name: test_switches[26.111111111111] + 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( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[26.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/26.111111111111/IAD', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[26.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/26.111111111111/IAD', + 'friendly_name': '26.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- +# name: test_switches[28.111111111111] + 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( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[28.111111111111].1 + list([ + ]) +# --- +# name: test_switches[28.111111111111].2 + list([ + ]) +# --- +# name: test_switches[28.222222222222] + 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( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[28.222222222222].1 + list([ + ]) +# --- +# name: test_switches[28.222222222222].2 + list([ + ]) +# --- +# name: test_switches[28.222222222223] + 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( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[28.222222222223].1 + list([ + ]) +# --- +# name: test_switches[28.222222222223].2 + list([ + ]) +# --- +# name: test_switches[29.111111111111] + 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( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[29.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_0', + 'unique_id': '/29.111111111111/PIO.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_1', + 'unique_id': '/29.111111111111/PIO.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_2', + 'unique_id': '/29.111111111111/PIO.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_3', + 'unique_id': '/29.111111111111/PIO.3', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 4', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_4', + 'unique_id': '/29.111111111111/PIO.4', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 5', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_5', + 'unique_id': '/29.111111111111/PIO.5', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 6', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_6', + 'unique_id': '/29.111111111111/PIO.6', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 7', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_7', + 'unique_id': '/29.111111111111/PIO.7', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_0', + 'unique_id': '/29.111111111111/latch.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_1', + 'unique_id': '/29.111111111111/latch.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_2', + 'unique_id': '/29.111111111111/latch.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_3', + 'unique_id': '/29.111111111111/latch.3', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 4', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_4', + 'unique_id': '/29.111111111111/latch.4', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 5', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_5', + 'unique_id': '/29.111111111111/latch.5', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 6', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_6', + 'unique_id': '/29.111111111111/latch.6', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 7', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'latch_7', + 'unique_id': '/29.111111111111/latch.7', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[29.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.0', + 'friendly_name': '29.111111111111 Programmed input-output 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.1', + 'friendly_name': '29.111111111111 Programmed input-output 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.2', + 'friendly_name': '29.111111111111 Programmed input-output 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.3', + 'friendly_name': '29.111111111111 Programmed input-output 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.4', + 'friendly_name': '29.111111111111 Programmed input-output 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.5', + 'friendly_name': '29.111111111111 Programmed input-output 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.6', + 'friendly_name': '29.111111111111 Programmed input-output 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.7', + 'friendly_name': '29.111111111111 Programmed input-output 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.0', + 'friendly_name': '29.111111111111 Latch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.1', + 'friendly_name': '29.111111111111 Latch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.2', + 'friendly_name': '29.111111111111 Latch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_2', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.3', + 'friendly_name': '29.111111111111 Latch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.4', + 'friendly_name': '29.111111111111 Latch 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_4', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.5', + 'friendly_name': '29.111111111111 Latch 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_5', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.6', + 'friendly_name': '29.111111111111 Latch 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_6', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.7', + 'friendly_name': '29.111111111111 Latch 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_7', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_switches[30.111111111111] + 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( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[30.111111111111].1 + list([ + ]) +# --- +# name: test_switches[30.111111111111].2 + list([ + ]) +# --- +# name: test_switches[3A.111111111111] + 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( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[3A.111111111111].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_a', + 'unique_id': '/3A.111111111111/PIO.A', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'pio_b', + 'unique_id': '/3A.111111111111/PIO.B', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[3A.111111111111].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.A', + 'friendly_name': '3A.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.B', + 'friendly_name': '3A.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_switches[3B.111111111111] + 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( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[3B.111111111111].1 + list([ + ]) +# --- +# name: test_switches[3B.111111111111].2 + list([ + ]) +# --- +# name: test_switches[42.111111111111] + 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( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[42.111111111111].1 + list([ + ]) +# --- +# name: test_switches[42.111111111111].2 + list([ + ]) +# --- +# name: test_switches[7E.111111111111] + 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( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[7E.111111111111].1 + list([ + ]) +# --- +# name: test_switches[7E.111111111111].2 + list([ + ]) +# --- +# name: test_switches[7E.222222222222] + 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( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[7E.222222222222].1 + list([ + ]) +# --- +# name: test_switches[7E.222222222222].2 + list([ + ]) +# --- +# name: test_switches[EF.111111111111] + 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( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[EF.111111111111].1 + list([ + ]) +# --- +# name: test_switches[EF.111111111111].2 + list([ + ]) +# --- +# name: test_switches[EF.111111111112] + 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( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[EF.111111111112].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'leaf_sensor_0', + 'unique_id': '/EF.111111111112/moisture/is_leaf.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'leaf_sensor_1', + 'unique_id': '/EF.111111111112/moisture/is_leaf.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'leaf_sensor_2', + 'unique_id': '/EF.111111111112/moisture/is_leaf.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'leaf_sensor_3', + 'unique_id': '/EF.111111111112/moisture/is_leaf.3', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_sensor_0', + 'unique_id': '/EF.111111111112/moisture/is_moisture.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_sensor_1', + 'unique_id': '/EF.111111111112/moisture/is_moisture.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_sensor_2', + 'unique_id': '/EF.111111111112/moisture/is_moisture.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'moisture_sensor_3', + 'unique_id': '/EF.111111111112/moisture/is_moisture.3', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[EF.111111111112].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.0', + 'friendly_name': 'EF.111111111112 Leaf sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.1', + 'friendly_name': 'EF.111111111112 Leaf sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.2', + 'friendly_name': 'EF.111111111112 Leaf sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.3', + 'friendly_name': 'EF.111111111112 Leaf sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.0', + 'friendly_name': 'EF.111111111112 Moisture sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.1', + 'friendly_name': 'EF.111111111112 Moisture sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.2', + 'friendly_name': 'EF.111111111112 Moisture sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.3', + 'friendly_name': 'EF.111111111112 Moisture sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_switches[EF.111111111113] + 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( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switches[EF.111111111113].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 0', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_branch_0', + 'unique_id': '/EF.111111111113/hub/branch.0', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 1', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_branch_1', + 'unique_id': '/EF.111111111113/hub/branch.1', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 2', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_branch_2', + 'unique_id': '/EF.111111111113/hub/branch.2', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 3', + 'platform': 'onewire', + 'supported_features': 0, + 'translation_key': 'hub_branch_3', + 'unique_id': '/EF.111111111113/hub/branch.3', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switches[EF.111111111113].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.0', + 'friendly_name': 'EF.111111111113 Hub branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.1', + 'friendly_name': 'EF.111111111113 Hub branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.2', + 'friendly_name': 'EF.111111111113 Hub branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.3', + 'friendly_name': 'EF.111111111113 Hub branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 02cedc7f0d0..1e13da95651 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -1,23 +1,16 @@ """Tests for 1-Wire binary sensors.""" from collections.abc import Generator -import logging from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.config_validation import ensure_list -from . import ( - check_and_enable_disabled_entities, - check_device_registry, - check_entities, - setup_owproxy_mock_devices, -) -from .const import ATTR_DEVICE_INFO, ATTR_UNKNOWN_DEVICE, MOCK_OWPROXY_DEVICES +from . import setup_owproxy_mock_devices @pytest.fixture(autouse=True) @@ -32,33 +25,34 @@ async def test_binary_sensors( config_entry: ConfigEntry, owproxy: MagicMock, device_id: str, - caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire binary sensor. + """Test for 1-Wire binary sensors.""" + setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - This test forces all entities to be enabled. - """ - mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(Platform.BINARY_SENSOR, []) - expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) - with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - if mock_device.get(ATTR_UNKNOWN_DEVICE): - assert "Ignoring unknown device family/type" in caplog.text - else: - assert "Ignoring unknown device family/type" not in caplog.text - - check_device_registry(device_registry, expected_devices) - assert len(entity_registry.entities) == len(expected_entities) - check_and_enable_disabled_entities(entity_registry, expected_entities) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + # 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 diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 4604a74177d..4d6ba3ca118 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -1,30 +1,19 @@ """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 import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry 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 homeassistant.helpers.config_validation import ensure_list -from . import ( - check_and_enable_disabled_entities, - check_device_registry, - check_entities, - setup_owproxy_mock_devices, -) -from .const import ( - ATTR_DEVICE_INFO, - ATTR_INJECT_READS, - ATTR_UNKNOWN_DEVICE, - MOCK_OWPROXY_DEVICES, -) +from . import setup_owproxy_mock_devices +from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES @pytest.fixture(autouse=True) @@ -39,40 +28,37 @@ async def test_sensors( config_entry: ConfigEntry, owproxy: MagicMock, device_id: str, - caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire device. + """Test for 1-Wire sensors.""" + 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() - As they would be on a clean setup: all binary-sensors and switches disabled. - """ - mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(Platform.SENSOR, []) - if "branches" in mock_device: - for branch_details in mock_device["branches"].values(): - for sub_device in branch_details.values(): - expected_entities += sub_device[Platform.SENSOR] - expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - if mock_device.get(ATTR_UNKNOWN_DEVICE): - assert "Ignoring unknown device family/type" in caplog.text - else: - assert "Ignoring unknown device family/type" not in caplog.text - - check_device_registry(device_registry, expected_devices) - assert len(entity_registry.entities) == len(expected_entities) - check_and_enable_disabled_entities(entity_registry, expected_entities) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + # 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.parametrize("device_id", ["12.111111111111"]) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 7becf95af7c..5440cbee598 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -1,15 +1,14 @@ """Tests for 1-Wire switches.""" from collections.abc import Generator -import logging from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_STATE, SERVICE_TOGGLE, STATE_OFF, STATE_ON, @@ -17,15 +16,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.config_validation import ensure_list -from . import ( - check_and_enable_disabled_entities, - check_device_registry, - check_entities, - setup_owproxy_mock_devices, -) -from .const import ATTR_DEVICE_INFO, ATTR_UNKNOWN_DEVICE, MOCK_OWPROXY_DEVICES +from . import setup_owproxy_mock_devices @pytest.fixture(autouse=True) @@ -40,55 +32,72 @@ async def test_switches( config_entry: ConfigEntry, owproxy: MagicMock, device_id: str, - caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire switch. + """Test for 1-Wire switches.""" + setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - This test forces all entities to be enabled. - """ - mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(Platform.SWITCH, []) - expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) - with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - if mock_device.get(ATTR_UNKNOWN_DEVICE): - assert "Ignoring unknown device family/type" in caplog.text - else: - assert "Ignoring unknown device family/type" not in caplog.text - - check_device_registry(device_registry, expected_devices) - assert len(entity_registry.entities) == len(expected_entities) - check_and_enable_disabled_entities(entity_registry, expected_entities) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + # 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 - # Test TOGGLE service - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - if expected_entity[ATTR_STATE] == STATE_ON: - owproxy.return_value.read.side_effect = [b" 0"] - expected_entity[ATTR_STATE] = STATE_OFF - elif expected_entity[ATTR_STATE] == STATE_OFF: - owproxy.return_value.read.side_effect = [b" 1"] - expected_entity[ATTR_STATE] = STATE_ON +@pytest.mark.parametrize("device_id", ["05.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_toggle( + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + device_id: str, +) -> None: + """Test for 1-Wire switch TOGGLE service.""" + setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TOGGLE, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + entity_id = "switch.05_111111111111_programmed_input_output" - state = hass.states.get(entity_id) - assert state.state == expected_entity[ATTR_STATE] + # Test TOGGLE service to off + owproxy.return_value.read.side_effect = [b" 0"] + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + + # Test TOGGLE service to on + owproxy.return_value.read.side_effect = [b" 1"] + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index d90ec02159f..18de9839e1b 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -1,12 +1,21 @@ """Tests for the ONVIF integration.""" from unittest.mock import AsyncMock, MagicMock, patch +from onvif.exceptions import ONVIFError from zeep.exceptions import Fault from homeassistant import config_entries from homeassistant.components.onvif import config_flow from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH -from homeassistant.components.onvif.models import Capabilities, DeviceInfo, Profile +from homeassistant.components.onvif.models import ( + Capabilities, + DeviceInfo, + Profile, + PullPointManagerState, + Resolution, + Video, + WebHookManagerState, +) from homeassistant.const import HTTP_DIGEST_AUTHENTICATION from tests.common import MockConfigEntry @@ -31,12 +40,19 @@ def setup_mock_onvif_camera( with_interfaces=True, with_interfaces_not_implemented=False, with_serial=True, + profiles_transient_failure=False, + auth_fail=False, + update_xaddrs_fail=False, + no_profiles=False, + auth_failure=False, + wrong_port=False, ): """Prepare mock onvif.ONVIFCamera.""" devicemgmt = MagicMock() device_info = MagicMock() device_info.SerialNumber = SERIAL_NUMBER if with_serial else None + devicemgmt.GetDeviceInformation = AsyncMock(return_value=device_info) interface = MagicMock() @@ -59,9 +75,29 @@ def setup_mock_onvif_camera( profile2 = MagicMock() profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG" - media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) + if auth_fail: + media_service.GetProfiles = AsyncMock(side_effect=Fault("Authority failure")) + elif profiles_transient_failure: + media_service.GetProfiles = AsyncMock(side_effect=Fault("camera not ready")) + elif no_profiles: + media_service.GetProfiles = AsyncMock(return_value=[]) + else: + media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) - mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) + if wrong_port: + mock_onvif_camera.update_xaddrs = AsyncMock(side_effect=AttributeError) + elif auth_failure: + mock_onvif_camera.update_xaddrs = AsyncMock( + side_effect=Fault( + "not authorized", subcodes=[MagicMock(text="NotAuthorized")] + ) + ) + elif update_xaddrs_fail: + mock_onvif_camera.update_xaddrs = AsyncMock( + side_effect=ONVIFError("camera not ready") + ) + else: + mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) mock_onvif_camera.close = AsyncMock(return_value=None) @@ -83,7 +119,7 @@ def setup_mock_onvif_camera( mock_onvif_camera.side_effect = mock_constructor -def setup_mock_device(mock_device): +def setup_mock_device(mock_device, capabilities=None): """Prepare mock ONVIFDevice.""" mock_device.async_setup = AsyncMock(return_value=True) mock_device.available = True @@ -95,16 +131,20 @@ def setup_mock_device(mock_device): SERIAL_NUMBER, MAC, ) - mock_device.capabilities = Capabilities(imaging=True) + mock_device.capabilities = capabilities or Capabilities(imaging=True, ptz=True) profile1 = Profile( index=0, token="dummy", name="profile1", - video=None, + video=Video("any", Resolution(640, 480)), ptz=None, video_source_token=None, ) mock_device.profiles = [profile1] + mock_device.events = MagicMock( + webhook_manager=MagicMock(state=WebHookManagerState.STARTED), + pullpoint_manager=MagicMock(state=PullPointManagerState.PAUSED), + ) def mock_constructor(hass, config): """Fake the controller constructor.""" @@ -120,7 +160,8 @@ async def setup_onvif_integration( unique_id=MAC, entry_id="1", source=config_entries.SOURCE_USER, -): + capabilities=None, +) -> tuple[MockConfigEntry, MagicMock, MagicMock]: """Create an ONVIF config entry.""" if not config: config = { @@ -152,7 +193,7 @@ async def setup_onvif_integration( setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) # no discovery mock_discovery.return_value = [] - setup_mock_device(mock_device) + setup_mock_device(mock_device, capabilities=capabilities) mock_device.device = mock_onvif_camera await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 69631865880..21ef1cf3fc2 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -2,8 +2,13 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.onvif import config_flow +from homeassistant.components import dhcp +from homeassistant.components.onvif import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_DHCP +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from . import ( HOST, @@ -34,6 +39,16 @@ DISCOVERY = [ "MAC": "ee:dd:cc:bb:aa", }, ] +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname="any", + ip="5.6.7.8", + macaddress=MAC, +) +DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( + hostname="any", + ip="1.2.3.4", + macaddress=MAC, +) def setup_mock_discovery( @@ -313,6 +328,275 @@ async def test_flow_manual_entry(hass: HomeAssistant) -> None: } +async def test_flow_manual_entry_no_profiles(hass: HomeAssistant) -> None: + """Test that config flow when no profiles are returned.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, no_profiles=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_h264" + + +async def test_flow_manual_entry_no_mac(hass: HomeAssistant) -> None: + """Test that config flow when no mac address is returned.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera( + mock_onvif_camera, with_serial=False, with_interfaces=False + ) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_mac" + + +async def test_flow_manual_entry_fails(hass: HomeAssistant) -> None: + """Test that we get a good error when manual entry fails.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera( + mock_onvif_camera, two_profiles=True, profiles_transient_failure=True + ) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + assert result["errors"] == {"base": "onvif_error"} + assert result["description_placeholders"] == {"error": "camera not ready"} + setup_mock_onvif_camera( + mock_onvif_camera, two_profiles=True, update_xaddrs_fail=True + ) + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + assert result["errors"] == {"base": "onvif_error"} + assert result["description_placeholders"] == { + "error": "Unknown error: camera not ready" + } + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result["title"] == f"{NAME} - {MAC}" + assert result["data"] == { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + } + + +async def test_flow_manual_entry_wrong_password(hass: HomeAssistant) -> None: + """Test that we get a an auth error with the wrong password.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True, auth_fail=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + assert result["errors"] == {"password": "auth_failed"} + assert result["description_placeholders"] == {"error": "Authority failure"} + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result["title"] == f"{NAME} - {MAC}" + assert result["data"] == { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + } + + async def test_option_flow(hass: HomeAssistant) -> None: """Test config flow options.""" entry, _, _ = await setup_onvif_integration(hass) @@ -339,3 +623,288 @@ async def test_option_flow(hass: HomeAssistant) -> None: config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, } + + +async def test_discovered_by_dhcp_updates_host(hass: HomeAssistant) -> None: + """Test dhcp updates existing host.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == "1.2.3.4" + await hass.config_entries.async_unload(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY.ip + + +async def test_discovered_by_dhcp_does_nothing_if_host_is_the_same( + hass: HomeAssistant, +) -> None: + """Test dhcp update does nothing if host is the same.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY_SAME_IP.ip + await hass.config_entries.async_unload(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_SAME_IP + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY_SAME_IP.ip + + +async def test_discovered_by_dhcp_does_not_update_if_already_loaded( + hass: HomeAssistant, +) -> None: + """Test dhcp does not update existing host if its already loaded.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] != DHCP_DISCOVERY.ip + + +async def test_discovered_by_dhcp_does_not_update_if_no_matching_entry( + hass: HomeAssistant, +) -> None: + """Test dhcp does not update existing host if there are no matching entries.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_form_reauth(hass: HomeAssistant) -> None: + """Test reauthenticate.""" + entry, _, _ = await setup_onvif_integration(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device, patch( + "homeassistant.components.onvif.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + setup_mock_onvif_camera(mock_onvif_camera, auth_failure=True) + setup_mock_device(mock_device) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + config_flow.CONF_USERNAME: "new-test-username", + config_flow.CONF_PASSWORD: "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {config_flow.CONF_PASSWORD: "auth_failed"} + assert result2["description_placeholders"] == { + "error": "not authorized (subcodes:NotAuthorized)" + } + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device, patch( + "homeassistant.components.onvif.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + setup_mock_onvif_camera(mock_onvif_camera) + setup_mock_device(mock_device) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + config_flow.CONF_USERNAME: "new-test-username", + config_flow.CONF_PASSWORD: "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.data[config_flow.CONF_USERNAME] == "new-test-username" + assert entry.data[config_flow.CONF_PASSWORD] == "new-test-password" + + +async def test_flow_manual_entry_updates_existing_user_password( + hass: HomeAssistant, +) -> None: + """Test that the existing username and password can be updated via manual entry.""" + entry, _, _ = await setup_onvif_integration(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: "new_password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[config_flow.CONF_USERNAME] == USERNAME + assert entry.data[config_flow.CONF_PASSWORD] == "new_password" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_manual_entry_wrong_port(hass: HomeAssistant) -> None: + """Test that we get a useful error with the wrong port.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, wrong_port=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + assert result["errors"] == {"port": "no_onvif_service"} + assert result["description_placeholders"] == {} + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result["title"] == f"{NAME} - {MAC}" + assert result["data"] == { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + } diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index 6a81f14fe5b..70dafe960b4 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -55,7 +55,7 @@ async def test_diagnostics( "capabilities": { "snapshot": False, "events": False, - "ptz": False, + "ptz": True, "imaging": True, }, "profiles": [ @@ -63,10 +63,23 @@ async def test_diagnostics( "index": 0, "token": "dummy", "name": "profile1", - "video": None, + "video": { + "encoding": "any", + "resolution": {"width": 640, "height": 480}, + }, "ptz": None, "video_source_token": None, } ], }, + "events": { + "pullpoint_manager_state": { + "__type": "", + "repr": "", + }, + "webhook_manager_state": { + "__type": "", + "repr": "", + }, + }, } diff --git a/tests/components/onvif/test_switch.py b/tests/components/onvif/test_switch.py index ef20586e0bf..1228f72ba22 100644 --- a/tests/components/onvif/test_switch.py +++ b/tests/components/onvif/test_switch.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNO from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MAC, setup_onvif_integration +from . import MAC, Capabilities, setup_onvif_integration async def test_wiper_switch(hass: HomeAssistant) -> None: @@ -24,6 +24,16 @@ async def test_wiper_switch(hass: HomeAssistant) -> None: assert entry.unique_id == f"{MAC}_wiper" +async def test_wiper_switch_no_ptz(hass: HomeAssistant) -> None: + """Test the wiper switch does not get created if the camera does not support ptz.""" + _config, _camera, device = await setup_onvif_integration( + hass, capabilities=Capabilities(imaging=True, ptz=False) + ) + device.profiles = device.async_get_profiles() + + assert hass.states.get("switch.testcamera_wiper") is None + + async def test_turn_wiper_switch_on(hass: HomeAssistant) -> None: """Test Wiper switch turn on.""" _, _camera, device = await setup_onvif_integration(hass) @@ -75,6 +85,16 @@ async def test_autofocus_switch(hass: HomeAssistant) -> None: assert entry.unique_id == f"{MAC}_autofocus" +async def test_auto_focus_switch_no_imaging(hass: HomeAssistant) -> None: + """Test the autofocus switch does not get created if the camera does not support imaging.""" + _config, _camera, device = await setup_onvif_integration( + hass, capabilities=Capabilities(imaging=False, ptz=True) + ) + device.profiles = device.async_get_profiles() + + assert hass.states.get("switch.testcamera_autofocus") is None + + async def test_turn_autofocus_switch_on(hass: HomeAssistant) -> None: """Test autofocus switch turn on.""" _, _camera, device = await setup_onvif_integration(hass) @@ -126,6 +146,16 @@ async def test_infrared_switch(hass: HomeAssistant) -> None: assert entry.unique_id == f"{MAC}_ir_lamp" +async def test_infrared_switch_no_imaging(hass: HomeAssistant) -> None: + """Test the infrared switch does not get created if the camera does not support imaging.""" + _config, _camera, device = await setup_onvif_integration( + hass, capabilities=Capabilities(imaging=False, ptz=False) + ) + device.profiles = device.async_get_profiles() + + assert hass.states.get("switch.testcamera_ir_lamp") is None + + async def test_turn_infrared_switch_on(hass: HomeAssistant) -> None: """Test infrared switch turn on.""" _, _camera, device = await setup_onvif_integration(hass) diff --git a/tests/components/open_meteo/test_config_flow.py b/tests/components/open_meteo/test_config_flow.py index c81d4da8c91..2eda6a8192b 100644 --- a/tests/components/open_meteo/test_config_flow.py +++ b/tests/components/open_meteo/test_config_flow.py @@ -20,7 +20,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 4ce677d8cca..471be8035b6 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -13,20 +13,18 @@ from homeassistant.components.openai_conversation.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType - -async def test_single_instance_allowed( - hass: HomeAssistant, mock_config_entry: config_entries.ConfigEntry -) -> None: - """Test that config flow only allows a single instance.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" +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("openai_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} ) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 144d77beab5..4016ac03c97 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -137,3 +137,15 @@ async def test_template_error( 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 OpenAIAgent.""" + agent = await conversation._get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert agent.supported_languages == "*" diff --git a/tests/components/openalpr_cloud/test_image_processing.py b/tests/components/openalpr_cloud/test_image_processing.py index 201c331a2e7..dfda0b0d282 100644 --- a/tests/components/openalpr_cloud/test_image_processing.py +++ b/tests/components/openalpr_cloud/test_image_processing.py @@ -14,6 +14,12 @@ from tests.components.image_processing import common from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture async def setup_openalpr_cloud(hass): """Set up openalpr cloud.""" diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 28ba175cbef..2bd62936fe5 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -47,7 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} result = await hass.config_entries.flow.async_init( diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index b788c93610d..9fe30d709a7 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -21,6 +21,7 @@ HASSIO_DATA = hassio.HassioServiceInfo( config={"host": "core-silabs-multiprotocol", "port": 8081}, name="Silicon Labs Multiprotocol", slug="otbr", + uuid="12345", ) @@ -203,7 +204,7 @@ async def test_hassio_discovery_flow( assert config_entry.data == expected_data assert config_entry.options == {} assert config_entry.title == "Open Thread Border Router" - assert config_entry.unique_id == otbr.DOMAIN + assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_router_not_setup( @@ -255,7 +256,7 @@ async def test_hassio_discovery_flow_router_not_setup( assert config_entry.data == expected_data assert config_entry.options == {} assert config_entry.title == "Open Thread Border Router" - assert config_entry.unique_id == otbr.DOMAIN + assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_router_not_setup_has_preferred( @@ -304,7 +305,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert config_entry.data == expected_data assert config_entry.options == {} assert config_entry.title == "Open Thread Border Router" - assert config_entry.unique_id == otbr.DOMAIN + assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( @@ -366,7 +367,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( assert config_entry.data == expected_data assert config_entry.options == {} assert config_entry.title == "Open Thread Border Router" - assert config_entry.unique_id == otbr.DOMAIN + assert config_entry.unique_id == HASSIO_DATA.uuid async def test_hassio_discovery_flow_404( diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index 98dfe184c13..419f24871ef 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -17,7 +17,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" with patch( "homeassistant.components.p1_monitor.config_flow.P1Monitor.smartmeter" diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 21260d85d18..433c9529e78 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -7,7 +7,12 @@ import pytest from homeassistant.components import person from homeassistant.components.device_tracker import ATTR_SOURCE_TYPE, SourceType -from homeassistant.components.person import ATTR_SOURCE, ATTR_USER_ID, DOMAIN +from homeassistant.components.person import ( + ATTR_DEVICE_TRACKERS, + ATTR_SOURCE, + ATTR_USER_ID, + DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_GPS_ACCURACY, @@ -35,7 +40,6 @@ def storage_collection(hass): id_manager = collection.IDManager() return person.PersonStorageCollection( person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), - logging.getLogger(f"{person.__name__}.storage_collection"), id_manager, collection.YamlCollection( logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager @@ -166,6 +170,7 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> assert state.attributes.get(ATTR_LONGITUDE) is None assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] hass.states.async_set( DEVICE_TRACKER, @@ -182,6 +187,7 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> assert state.attributes.get(ATTR_GPS_ACCURACY) == 10 assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] async def test_setup_two_trackers( @@ -221,6 +227,10 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_GPS_ACCURACY) is None assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] hass.states.async_set( DEVICE_TRACKER_2, @@ -246,6 +256,10 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] hass.states.async_set(DEVICE_TRACKER_2, "zone1", {ATTR_SOURCE_TYPE: SourceType.GPS}) await hass.async_block_till_done() diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py new file mode 100644 index 00000000000..879db2ec11f --- /dev/null +++ b/tests/components/person/test_recorder.py @@ -0,0 +1,45 @@ +"""The tests for update recorder.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.person import ATTR_DEVICE_TRACKERS, DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +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.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes( + recorder_mock: Recorder, + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test update attributes to be excluded.""" + now = dt_util.utcnow() + config = { + DOMAIN: { + "id": "1234", + "name": "test person", + "user_id": "test_user_id", + "device_trackers": ["device_tracker.test"], + } + } + assert await async_setup_component(hass, DOMAIN, config) + + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_DEVICE_TRACKERS not in state.attributes diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..69c77acc64a --- /dev/null +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'ads_blocked_today': 0, + 'ads_percentage_today': 0, + 'clients_ever_seen': 0, + 'dns_queries_today': 0, + 'domains_being_blocked': 0, + 'queries_cached': 0, + 'queries_forwarded': 0, + 'status': 'disabled', + 'unique_clients': 0, + 'unique_domains': 0, + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'host': '1.2.3.4:80', + 'location': 'admin', + 'name': 'Pi-Hole', + 'ssl': False, + 'verify_ssl': True, + }), + 'disabled_by': None, + 'domain': 'pi_hole', + 'entry_id': 'pi_hole_mock_entry', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'versions': dict({ + 'FTL_current': 'v5.10', + 'FTL_latest': 'v5.11', + 'FTL_update': True, + 'core_current': 'v5.5', + 'core_latest': 'v5.6', + 'core_update': True, + 'web_current': 'v5.7', + 'web_latest': 'v5.8', + 'web_update': True, + }), + }) +# --- diff --git a/tests/components/pi_hole/test_diagnostics.py b/tests/components/pi_hole/test_diagnostics.py new file mode 100644 index 00000000000..c9fc9a0a9b8 --- /dev/null +++ b/tests/components/pi_hole/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Test pi_hole component.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import pi_hole +from homeassistant.core import HomeAssistant + +from . import CONFIG_DATA_DEFAULTS, _create_mocked_hole, _patch_init_hole + +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, + snapshot: SnapshotAssertion, +) -> None: + """Tests diagnostics.""" + mocked_hole = _create_mocked_hole() + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS, entry_id="pi_hole_mock_entry" + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 8d680327bc8..0d003547ef2 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,7 +9,8 @@ import requests from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, SENSOR_TYPES +from homeassistant.components.picnic.const import CONF_COUNTRY_CODE +from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 08b8635829f..bc43a1e0d89 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -360,3 +360,19 @@ async def test_trigger_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["source"] == SOURCE_REAUTH + + +async def test_setup_with_deauthorized_token( + hass: HomeAssistant, entry, setup_plex_server +) -> None: + """Test setup with a deauthorized token.""" + with patch( + "plexapi.server.PlexServer", + side_effect=plexapi.exceptions.BadRequest(const.INVALID_TOKEN_MESSAGE), + ): + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH diff --git a/tests/components/poolsense/test_config_flow.py b/tests/components/poolsense/test_config_flow.py index 6b3a8f7ea6f..71303e48dbf 100644 --- a/tests/components/poolsense/test_config_flow.py +++ b/tests/components/poolsense/test_config_flow.py @@ -15,7 +15,7 @@ async def test_show_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" async def test_invalid_credentials(hass: HomeAssistant) -> None: diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 0cafa9ed7aa..ca998f25f5f 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -2,11 +2,11 @@ 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 -import py import pytest from homeassistant.components.profiler import ( @@ -33,9 +33,10 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -async def test_basic_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: +async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" - test_dir = tmpdir.mkdir("profiles") + test_dir = tmp_path / "profiles" + test_dir.mkdir() entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) @@ -47,9 +48,9 @@ async def test_basic_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: last_filename = None - def _mock_path(filename): + def _mock_path(filename: str) -> str: nonlocal last_filename - last_filename = f"{test_dir}/{filename}" + last_filename = str(test_dir / filename) return last_filename with patch("cProfile.Profile"), patch.object(hass.config, "path", _mock_path): @@ -66,9 +67,10 @@ async def test_basic_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: @pytest.mark.skipif( sys.version_info >= (3, 11), reason="not yet available on python 3.11" ) -async def test_memory_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: +async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" - test_dir = tmpdir.mkdir("profiles") + test_dir = tmp_path / "profiles" + test_dir.mkdir() entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) @@ -80,9 +82,9 @@ async def test_memory_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: last_filename = None - def _mock_path(filename): + def _mock_path(filename: str) -> str: nonlocal last_filename - last_filename = f"{test_dir}/{filename}" + last_filename = str(test_dir / filename) return last_filename with patch("guppy.hpy") as mock_hpy, patch.object(hass.config, "path", _mock_path): @@ -97,7 +99,7 @@ async def test_memory_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: @pytest.mark.skipif(sys.version_info < (3, 11), reason="still works on python 3.10") -async def test_memory_usage_py311(hass: HomeAssistant, tmpdir: py.path.local) -> None: +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) diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py index bd2ce231e28..91bc7f88405 100644 --- a/tests/components/prosegur/conftest.py +++ b/tests/components/prosegur/conftest.py @@ -27,6 +27,15 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_list_contracts() -> AsyncMock: + """Return list of contracts per user.""" + return [ + {"contractId": "123", "description": "a b c"}, + {"contractId": "456", "description": "x y z"}, + ] + + @pytest.fixture def mock_install() -> AsyncMock: """Return the mocked alarm install.""" diff --git a/tests/components/prosegur/test_config_flow.py b/tests/components/prosegur/test_config_flow.py index 2f57f950a85..2c08f9de109 100644 --- a/tests/components/prosegur/test_config_flow.py +++ b/tests/components/prosegur/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Prosegur Alarm config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -12,7 +12,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_list_contracts) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -21,12 +21,9 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - install = MagicMock() - install.contract = "123" - with patch( - "homeassistant.components.prosegur.config_flow.Installation.retrieve", - return_value=install, + "homeassistant.components.prosegur.config_flow.Installation.list", + return_value=mock_list_contracts, ) as mock_retrieve, patch( "homeassistant.components.prosegur.async_setup_entry", return_value=True, @@ -41,9 +38,15 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == "Contract 123" - assert result2["data"] == { + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"contract": "123"}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "Contract 123" + assert result3["data"] == { "contract": "123", "username": "test-username", "password": "test-password", @@ -61,7 +64,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) with patch( - "pyprosegur.installation.Installation", + "pyprosegur.installation.Installation.list", side_effect=ConnectionRefusedError, ): result2 = await hass.config_entries.flow.async_configure( @@ -84,7 +87,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "pyprosegur.installation.Installation", + "homeassistant.components.prosegur.config_flow.Installation.list", side_effect=ConnectionError, ): result2 = await hass.config_entries.flow.async_configure( @@ -123,7 +126,7 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow(hass: HomeAssistant, mock_list_contracts) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -149,12 +152,9 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {} - install = MagicMock() - install.contract = "123" - with patch( - "homeassistant.components.prosegur.config_flow.Installation.retrieve", - return_value=install, + "homeassistant.components.prosegur.config_flow.Installation.list", + return_value=mock_list_contracts, ) as mock_installation, patch( "homeassistant.components.prosegur.async_setup_entry", return_value=True, @@ -212,7 +212,7 @@ async def test_reauth_flow_error(hass: HomeAssistant, exception, base_error) -> ) with patch( - "homeassistant.components.prosegur.config_flow.Installation.retrieve", + "homeassistant.components.prosegur.config_flow.Installation.list", side_effect=exception, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 391579359b6..8beb67b0ed4 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -42,8 +42,8 @@ def mock_printer_api(hass): "material": "PLA", }, "temperature": { - "tool0": {"actual": 47.8, "target": 0.0, "display": 0.0, "offset": 0}, - "bed": {"actual": 41.9, "target": 0.0, "offset": 0}, + "tool0": {"actual": 47.8, "target": 210.1, "display": 0.0, "offset": 0}, + "bed": {"actual": 41.9, "target": 60.5, "offset": 0}, }, "state": { "text": "Operational", diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 4f64c3139e1..6a0944bdf36 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -14,7 +14,9 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, Platform, + UnitOfLength, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -63,6 +65,36 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.mock_title_heatbed_target_temperature") + assert state is not None + assert state.state == "60.5" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_nozzle_target_temperature") + assert state is not None + assert state.state == "210.1" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_z_height") + assert state is not None + assert state.state == "1.8" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DISTANCE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + state = hass.states.get("sensor.mock_title_print_speed") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + + state = hass.states.get("sensor.mock_title_material") + assert state is not None + assert state.state == "PLA" + state = hass.states.get("sensor.mock_title_progress") assert state is not None assert state.state == "unavailable" diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index ebd7aefff20..2b00e975a8e 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -22,7 +22,7 @@ async def test_full_user_flow_implementation( context={"source": SOURCE_USER}, ) - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" assert result.get("type") == FlowResultType.FORM result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index 36a783f86fb..bf05afa020d 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -24,7 +24,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -60,7 +60,7 @@ async def test_full_flow_with_authentication_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_pvoutput_config_flow.system.side_effect = PVOutputAuthenticationError result2 = await hass.config_entries.flow.async_configure( @@ -72,7 +72,7 @@ async def test_full_flow_with_authentication_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/qbittorrent/__init__.py b/tests/components/qbittorrent/__init__.py new file mode 100644 index 00000000000..2be020668c1 --- /dev/null +++ b/tests/components/qbittorrent/__init__.py @@ -0,0 +1 @@ +"""Tests for the qBittorrent integration.""" diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py new file mode 100644 index 00000000000..448f68db81e --- /dev/null +++ b/tests/components/qbittorrent/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for testing qBittorrent component.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +import requests_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock qbittorrent entry setup.""" + with patch( + "homeassistant.components.qbittorrent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api() -> Generator[requests_mock.Mocker, None, None]: + """Mock the qbittorrent API.""" + with requests_mock.Mocker() as mocker: + mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) + mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") + mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") + yield mocker diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py new file mode 100644 index 00000000000..b7244ccef8d --- /dev/null +++ b/tests/components/qbittorrent/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the qBittorrent config flow.""" +import pytest +from requests.exceptions import RequestException +import requests_mock + +from homeassistant.components.qbittorrent.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SOURCE, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, +} + +YAML_IMPORT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + + +async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: + """Test the user flow.""" + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with connection failure, fail with cannot_connect + with requests_mock.Mocker() as mock: + mock.get( + f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", + exc=RequestException, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Test flow with wrong creds, fail with invalid_auth + with requests_mock.Mocker() as mock: + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Wrong username/password", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Test flow with proper input, succeed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with duplicate config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 7c2153d5f90..ab35a9369ea 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/rapt_ble/__init__.py b/tests/components/rapt_ble/__init__.py new file mode 100644 index 00000000000..4b3959fdba7 --- /dev/null +++ b/tests/components/rapt_ble/__init__.py @@ -0,0 +1,28 @@ +"""Tests for the rapt_ble integration.""" + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +RAPT_MAC = "78:E3:6D:3C:06:66" + +NOT_RAPT_SERVICE_INFO = BluetoothServiceInfo( + name="Not it", + address="61DE521B-F0BF-9F44-64D4-75BBE1738105", + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +COMPLETE_SERVICE_INFO = BluetoothServiceInfo( + name="", + address=RAPT_MAC, + rssi=-70, + manufacturer_data={ + 16722: b"PT\x01x\xe3m<\xb9\x94\x94{D|\xc5 47\x02a&\x89*\x83", + 17739: b"G20220612_050156_81c6d14", + }, + service_data={}, + service_uuids=[], + source="local", +) diff --git a/tests/components/rapt_ble/conftest.py b/tests/components/rapt_ble/conftest.py new file mode 100644 index 00000000000..4a890eb60f1 --- /dev/null +++ b/tests/components/rapt_ble/conftest.py @@ -0,0 +1,8 @@ +"""RAPT BLE session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/rapt_ble/test_config_flow.py b/tests/components/rapt_ble/test_config_flow.py new file mode 100644 index 00000000000..46b7265b238 --- /dev/null +++ b/tests/components/rapt_ble/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test the RAPT config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.rapt_ble.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import COMPLETE_SERVICE_INFO, NOT_RAPT_SERVICE_INFO, RAPT_MAC + +from tests.common import MockConfigEntry + + +async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=COMPLETE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + with patch( + "homeassistant.components.rapt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "RAPT Pill 0666" + assert result2["data"] == {} + assert result2["result"].unique_id == RAPT_MAC + + +async def test_async_step_bluetooth_not_rapt(hass: HomeAssistant) -> None: + """Test discovery via bluetooth not RAPT.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_RAPT_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant) -> None: + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.rapt_ble.config_flow.async_discovered_service_info", + return_value=[COMPLETE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.rapt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": RAPT_MAC}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "RAPT Pill 0666" + assert result2["data"] == {} + assert result2["result"].unique_id == RAPT_MAC + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.rapt_ble.config_flow.async_discovered_service_info", + return_value=[COMPLETE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=RAPT_MAC, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.rapt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": RAPT_MAC}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup( + hass: HomeAssistant, +) -> None: + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=RAPT_MAC, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.rapt_ble.config_flow.async_discovered_service_info", + return_value=[COMPLETE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant) -> None: + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=RAPT_MAC, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=COMPLETE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> None: + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=COMPLETE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=COMPLETE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_async_step_user_takes_precedence_over_discovery( + hass: HomeAssistant, +) -> None: + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=COMPLETE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.rapt_ble.config_flow.async_discovered_service_info", + return_value=[COMPLETE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.rapt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": RAPT_MAC}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "RAPT Pill 0666" + assert result2["data"] == {} + assert result2["result"].unique_id == RAPT_MAC + + # Verify the original one was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) diff --git a/tests/components/rapt_ble/test_sensor.py b/tests/components/rapt_ble/test_sensor.py new file mode 100644 index 00000000000..c610cc526b8 --- /dev/null +++ b/tests/components/rapt_ble/test_sensor.py @@ -0,0 +1,66 @@ +"""Test the RAPT Pill BLE sensors.""" + +from __future__ import annotations + +from homeassistant.components.rapt_ble.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant + +from . import COMPLETE_SERVICE_INFO, RAPT_MAC + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_sensors(hass: HomeAssistant): + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=RAPT_MAC, + ) + 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, COMPLETE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.rapt_pill_0666_battery") + assert temp_sensor is not None + + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "43" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "RAPT Pill 0666 Battery" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert temp_sensor_attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + temp_sensor = hass.states.get("sensor.rapt_pill_0666_temperature") + assert temp_sensor is not None + + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "23.81" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "RAPT Pill 0666 Temperature" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert temp_sensor_attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + temp_sensor = hass.states.get("sensor.rapt_pill_0666_specific_gravity") + assert temp_sensor is not None + + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "1.0111" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "RAPT Pill 0666 Specific Gravity" + ) + assert temp_sensor_attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/rdw/test_config_flow.py b/tests/components/rdw/test_config_flow.py index 8dc0dac8b9d..b8c21be300e 100644 --- a/tests/components/rdw/test_config_flow.py +++ b/tests/components/rdw/test_config_flow.py @@ -19,7 +19,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -46,7 +46,7 @@ async def test_full_flow_with_authentication_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_rdw_config_flow.vehicle.side_effect = RDWUnknownLicensePlateError result2 = await hass.config_entries.flow.async_configure( @@ -57,7 +57,7 @@ async def test_full_flow_with_authentication_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"base": "unknown_license_plate"} mock_rdw_config_flow.vehicle.side_effect = None diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index b19ff4ca503..1fd5d769c7c 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -74,3 +74,32 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( "Updating character set and collation of table event_data to utf8mb4" in caplog.text ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"events.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: events.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table events to utf8mb4" in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 2e37001582e..9b90489d7c0 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -104,3 +104,32 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( "Updating character set and collation of table state_attributes to utf8mb4" in caplog.text ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"states.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table states to utf8mb4" in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 53dac0b6ab2..98f46cadf03 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -3,10 +3,10 @@ from collections.abc import Callable # pylint: disable=invalid-name import importlib +from pathlib import Path import sys from unittest.mock import patch -import py import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -129,10 +129,12 @@ def _create_engine_28(*args, **kwargs): def test_delete_metadata_duplicates( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" module = "tests.components.recorder.db_schema_28" @@ -222,10 +224,12 @@ def test_delete_metadata_duplicates( def test_delete_metadata_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" module = "tests.components.recorder.db_schema_28" diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index dfe036355aa..10d1ed00b5b 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -83,3 +83,33 @@ async def test_validate_db_schema_fix_float_issue( "sum DOUBLE PRECISION", ] modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"statistics.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: statistics.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table statistics to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 510f46f98a2..ad2c33bfb88 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -10,6 +10,7 @@ from homeassistant.components.recorder.auto_repairs.schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) from homeassistant.components.recorder.db_schema import States @@ -106,6 +107,69 @@ async def test_validate_db_schema_fix_utf8_issue_with_broken_schema( assert schema_errors == set() +async def test_validate_db_schema_fix_incorrect_collation( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the collation is incorrect.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states CHARACTER SET utf8mb3 COLLATE utf8_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, instance, States + ) + assert schema_errors == {"states.utf8mb4_unicode_ci"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_utf8, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, instance, States + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_correct_collation( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is correct with the correct collation.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, + instance, + States, + ) + assert schema_errors == set() + + async def test_validate_db_schema_fix_utf8_issue_with_broken_schema_unrepairable( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 0da58cce6c5..e017aa384f7 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -2,9 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Iterable, Iterator +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial +import importlib +import sys import time from typing import Any, Literal, cast from unittest.mock import patch, sentinel @@ -14,8 +18,14 @@ from sqlalchemy.orm.session import Session from homeassistant import core as ha from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, get_instance, statistics -from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder import Recorder, core, get_instance, statistics +from homeassistant.components.recorder.db_schema import ( + Events, + EventTypes, + RecorderRuns, + States, + StatesMeta, +) from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State @@ -24,6 +34,7 @@ import homeassistant.util.dt as dt_util from . import db_schema_0 DEFAULT_PURGE_TASKS = 3 +CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" @dataclass @@ -307,3 +318,123 @@ def record_states(hass): states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) return zero, four, states + + +def convert_pending_states_to_meta(instance: Recorder, session: Session) -> None: + """Convert pending states to use states_metadata.""" + entity_ids: set[str] = set() + states: set[States] = set() + states_meta_objects: dict[str, StatesMeta] = {} + for object in session: + if isinstance(object, States): + entity_ids.add(object.entity_id) + states.add(object) + + entity_id_to_metadata_ids = instance.states_meta_manager.get_many( + entity_ids, session, True + ) + + for state in states: + entity_id = state.entity_id + state.entity_id = None + state.attributes = None + state.event_id = None + if metadata_id := entity_id_to_metadata_ids.get(entity_id): + state.metadata_id = metadata_id + continue + if entity_id not in states_meta_objects: + states_meta_objects[entity_id] = StatesMeta(entity_id=entity_id) + state.states_meta_rel = states_meta_objects[entity_id] + + +def convert_pending_events_to_event_types(instance: Recorder, session: Session) -> None: + """Convert pending events to use event_type_ids.""" + event_types: set[str] = set() + events: set[Events] = set() + event_types_objects: dict[str, EventTypes] = {} + for object in session: + if isinstance(object, Events): + event_types.add(object.event_type) + events.add(object) + + event_type_to_event_type_ids = instance.event_type_manager.get_many( + event_types, session, True + ) + manually_added_event_types: list[str] = [] + + for event in events: + event_type = event.event_type + event.event_type = None + event.event_data = None + event.origin = None + if event_type_id := event_type_to_event_type_ids.get(event_type): + event.event_type_id = event_type_id + continue + if event_type not in event_types_objects: + event_types_objects[event_type] = EventTypes(event_type=event_type) + manually_added_event_types.append(event_type) + event.event_type_rel = event_types_objects[event_type] + + for event_type in manually_added_event_types: + instance.event_type_manager._non_existent_event_types.pop(event_type, None) + + +def create_engine_test_for_schema_version_postfix( + *args, schema_version_postfix: str, **kwargs +): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + schema_module = get_schema_module_path(schema_version_postfix) + importlib.import_module(schema_module) + old_db_schema = sys.modules[schema_module] + engine = create_engine(*args, **kwargs) + old_db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) + ) + session.commit() + return engine + + +def get_schema_module_path(schema_version_postfix: str) -> str: + """Return the path to the schema module.""" + return f"tests.components.recorder.db_schema_{schema_version_postfix}" + + +@contextmanager +def old_db_schema(schema_version_postfix: str) -> Iterator[None]: + """Fixture to initialize the db with the old schema.""" + schema_module = get_schema_module_path(schema_version_postfix) + importlib.import_module(schema_module) + old_db_schema = sys.modules[schema_module] + + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( + core, "States", old_db_schema.States + ), patch.object( + core, "Events", old_db_schema.Events + ), patch.object( + core, "StateAttributes", old_db_schema.StateAttributes + ), patch.object( + core, "EntityIDMigrationTask", core.RecorderTask + ), patch( + CREATE_ENGINE_TARGET, + new=partial( + create_engine_test_for_schema_version_postfix, + schema_version_postfix=schema_version_postfix, + ), + ): + yield diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 7f276d42df8..291fdb1231d 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -6,7 +6,7 @@ import json import logging from typing import Any, TypedDict, cast, overload -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( BigInteger, Boolean, diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 8127cb3f26f..7e88d6a5548 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -11,7 +11,7 @@ import logging import time from typing import Any, TypedDict, cast, overload -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( BigInteger, Boolean, diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 9c5efaea1d3..40417752719 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -12,7 +12,7 @@ import time from typing import Any, TypedDict, cast, overload import ciso8601 -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( JSON, BigInteger, diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 75d91cdf79c..03a71697227 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -12,7 +12,7 @@ import time from typing import Any, TypedDict, cast, overload import ciso8601 -from fnvhash import fnv1a_32 +from fnv_hash_fast import fnv1a_32 from sqlalchemy import ( JSON, BigInteger, @@ -59,7 +59,7 @@ ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEAT # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 30 +SCHEMA_VERSION = 32 _LOGGER = logging.getLogger(__name__) @@ -253,7 +253,8 @@ class Events(Base): # type: ignore[misc,valid-type] event_type=event.event_type, event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - time_fired=event.time_fired, + time_fired=None, + time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), context_id=event.context.id, context_user_id=event.context.user_id, context_parent_id=event.context.parent_id, @@ -268,12 +269,12 @@ class Events(Base): # type: ignore[misc,valid-type] ) try: return Event( - self.event_type, + self.event_type or "", json_loads(self.event_data) if self.event_data else {}, EventOrigin(self.origin) if self.origin - else EVENT_ORIGIN_ORDER[self.origin_idx], - process_timestamp(self.time_fired), + else EVENT_ORIGIN_ORDER[self.origin_idx or 0], + dt_util.utc_from_timestamp(self.time_fired_ts or 0), context=context, ) except JSON_DECODE_EXCEPTIONS: @@ -419,21 +420,22 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id=event.context.user_id, context_parent_id=event.context.parent_id, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + last_updated=None, + last_changed=None, ) - # None state means the state was removed from the state machine if state is None: dbstate.state = "" - dbstate.last_updated = event.time_fired - dbstate.last_changed = None + dbstate.last_updated_ts = dt_util.utc_to_timestamp(event.time_fired) + dbstate.last_changed_ts = None return dbstate dbstate.state = state.state - dbstate.last_updated = state.last_updated + dbstate.last_updated_ts = dt_util.utc_to_timestamp(state.last_updated) if state.last_updated == state.last_changed: - dbstate.last_changed = None + dbstate.last_changed_ts = None else: - dbstate.last_changed = state.last_changed + dbstate.last_changed_ts = dt_util.utc_to_timestamp(state.last_changed) return dbstate @@ -450,14 +452,16 @@ class States(Base): # type: ignore[misc,valid-type] # When json_loads fails _LOGGER.exception("Error converting row to state: %s", self) return None - if self.last_changed is None or self.last_changed == self.last_updated: - last_changed = last_updated = process_timestamp(self.last_updated) + if self.last_changed_ts is None or self.last_changed_ts == self.last_updated_ts: + last_changed = last_updated = dt_util.utc_from_timestamp( + self.last_updated_ts or 0 + ) else: - last_updated = process_timestamp(self.last_updated) - last_changed = process_timestamp(self.last_changed) + last_updated = dt_util.utc_from_timestamp(self.last_updated_ts or 0) + last_changed = dt_util.utc_from_timestamp(self.last_changed_ts or 0) return State( - self.entity_id, - self.state, + self.entity_id or "", + self.state, # type: ignore[arg-type] # Join the state_attributes table on attributes_id to get the attributes # for newer states attrs, @@ -558,16 +562,19 @@ class StatisticsBase: id = Column(Integer, Identity(), primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) + created_ts = Column(TIMESTAMP_TYPE, default=time.time) metadata_id = Column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), index=True, ) start = Column(DATETIME_TYPE, index=True) + start_ts = Column(TIMESTAMP_TYPE, index=True) mean = Column(DOUBLE_TYPE) min = Column(DOUBLE_TYPE) max = Column(DOUBLE_TYPE) last_reset = Column(DATETIME_TYPE) + last_reset_ts = Column(TIMESTAMP_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index 922032539c4..0d675574e12 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -60,7 +60,9 @@ def test_rename_entity_without_collision( hass.block_till_done() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -71,13 +73,20 @@ def test_rename_entity_without_collision( hass.add_job(rename_entry) wait_recording_done(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) states["sensor.test99"] = states.pop("sensor.test1") assert_dict_of_states_equal_without_context_and_last_changed(states, hist) hass.states.set("sensor.test99", "post_migrate") wait_recording_done(hass) - new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + new_hist = history.get_significant_states( + hass, + zero, + dt_util.utcnow(), + list(set(states) | {"sensor.test99", "sensor.test1"}), + ) assert not new_hist.get("sensor.test1") assert new_hist["sensor.test99"][-1].state == "post_migrate" @@ -207,7 +216,9 @@ def test_rename_entity_collision( hass.block_till_done() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) assert len(hist["sensor.test1"]) == 3 @@ -225,7 +236,9 @@ def test_rename_entity_collision( wait_recording_done(hass) # History is not migrated on collision - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) assert len(hist["sensor.test1"]) == 3 assert len(hist["sensor.test99"]) == 2 @@ -234,7 +247,12 @@ def test_rename_entity_collision( hass.states.set("sensor.test99", "post_migrate") wait_recording_done(hass) - new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + new_hist = history.get_significant_states( + hass, + zero, + dt_util.utcnow(), + list(set(states) | {"sensor.test99", "sensor.test1"}), + ) assert new_hist["sensor.test99"][-1].state == "post_migrate" assert len(hist["sensor.test99"]) == 2 diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index 18879ffc0a5..9829996818f 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,4 +1,5 @@ """The tests for the recorder filter matching the EntityFilter component.""" +# pylint: disable=invalid-name import json from unittest.mock import patch @@ -25,7 +26,15 @@ from homeassistant.helpers.entityfilter import ( convert_include_exclude_filter, ) -from .common import async_wait_recording_done +from .common import async_wait_recording_done, old_db_schema + + +# This test is for schema 37 and below (32 is new enough to test) +@pytest.fixture(autouse=True) +def db_schema_32(): + """Fixture to initialize the db with the old schema 32.""" + with old_db_schema("32"): + yield @pytest.fixture(name="legacy_recorder_mock") diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index e3aed8a3988..b9c44f486b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -21,9 +21,13 @@ from homeassistant.components.recorder.db_schema import ( States, StatesMeta, ) +from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.history import legacy -from homeassistant.components.recorder.models import LazyState, process_timestamp -from homeassistant.components.recorder.models.legacy import LazyStatePreSchema31 +from homeassistant.components.recorder.models import process_timestamp +from homeassistant.components.recorder.models.legacy import ( + LegacyLazyState, + LegacyLazyStatePreSchema31, +) from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State @@ -40,7 +44,6 @@ from .common import ( wait_recording_done, ) -from tests.common import mock_state_change_event from tests.typing import RecorderInstanceGenerator @@ -54,21 +57,24 @@ async def _async_get_states( """Get states from the database.""" def _get_states_with_session(): - if get_instance(hass).schema_version < 31: - klass = LazyStatePreSchema31 - else: - klass = LazyState - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: attr_cache = {} + pre_31_schema = get_instance(hass).schema_version < 31 return [ - klass(row, attr_cache, None) + LegacyLazyStatePreSchema31(row, attr_cache, None) + if pre_31_schema + else LegacyLazyState( + row, + attr_cache, + None, + row.entity_id, + ) for row in legacy._get_rows_with_session( hass, session, utc_point_in_time, entity_ids, run, - None, no_attributes, ) ] @@ -112,44 +118,6 @@ def _add_db_entries( ) -def _setup_get_states(hass): - """Set up for testing get_states.""" - states = [] - now = dt_util.utcnow() - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=now - ): - for i in range(5): - state = ha.State( - f"test.point_in_time_{i % 5}", - f"State {i}", - {"attribute_test": i}, - ) - - mock_state_change_event(hass, state) - - states.append(state) - - wait_recording_done(hass) - - future = now + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=future - ): - for i in range(5): - state = ha.State( - f"test.point_in_time_{i % 5}", - f"State {i}", - {"attribute_test": i}, - ) - - mock_state_change_event(hass, state) - - wait_recording_done(hass) - - return now, future, states - - def test_get_full_significant_states_with_session_entity_no_matches( hass_recorder: Callable[..., HomeAssistant] ) -> None: @@ -157,7 +125,7 @@ def test_get_full_significant_states_with_session_entity_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: assert ( history.get_full_significant_states_with_session( hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] @@ -183,7 +151,7 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: assert ( history.get_significant_states_with_session( hass, @@ -217,7 +185,7 @@ def test_significant_states_with_session_single_entity( hass.states.set("demo.id", "any2", {"attr": True}) wait_recording_done(hass) now = dt_util.utcnow() - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( hass, session, @@ -297,12 +265,12 @@ def test_state_changes_during_period_descending( wait_recording_done(hass) return hass.states.get(entity_id) - start = dt_util.utcnow() + start = dt_util.utcnow().replace(microsecond=0) point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=2) - point3 = start + timedelta(seconds=1, microseconds=3) - point4 = start + timedelta(seconds=1, microseconds=4) - end = point + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=100) + point3 = start + timedelta(seconds=1, microseconds=200) + 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 @@ -336,6 +304,7 @@ def test_state_changes_during_period_descending( hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes=False, descending=False ) + assert_multiple_states_equal_without_context(states, hist[entity_id]) hist = history.state_changes_during_period( @@ -345,6 +314,56 @@ def test_state_changes_during_period_descending( states, list(reversed(list(hist[entity_id]))) ) + start_time = point2 + timedelta(microseconds=10) + hist = history.state_changes_during_period( + hass, + start_time, # Pick a point where we will generate a start time state + end, + entity_id, + no_attributes=False, + descending=True, + include_start_time_state=True, + ) + hist_states = list(hist[entity_id]) + assert hist_states[-1].last_updated == start_time + assert hist_states[-1].last_changed == start_time + assert len(hist_states) == 3 + # Make sure they are in descending order + assert ( + hist_states[0].last_updated + > hist_states[1].last_updated + > hist_states[2].last_updated + ) + assert ( + hist_states[0].last_changed + > hist_states[1].last_changed + > hist_states[2].last_changed + ) + hist = history.state_changes_during_period( + hass, + start_time, # Pick a point where we will generate a start time state + end, + entity_id, + no_attributes=False, + descending=False, + include_start_time_state=True, + ) + hist_states = list(hist[entity_id]) + assert hist_states[0].last_updated == start_time + assert hist_states[0].last_changed == start_time + assert len(hist_states) == 3 + # Make sure they are in ascending order + assert ( + hist_states[0].last_updated + < hist_states[1].last_updated + < hist_states[2].last_updated + ) + assert ( + hist_states[0].last_changed + < hist_states[1].last_changed + < hist_states[2].last_changed + ) + def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test number of state changes.""" @@ -463,7 +482,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> """ hass = hass_recorder() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -481,7 +500,9 @@ def test_get_significant_states_minimal_response( """ hass = hass_recorder() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four, minimal_response=True) + hist = history.get_significant_states( + hass, zero, four, minimal_response=True, entity_ids=list(states) + ) entites_with_reducable_states = [ "media_player.test", "media_player.test3", @@ -546,20 +567,20 @@ def test_get_significant_states_with_initial( hass = hass_recorder() hass.config.set_time_zone(time_zone) zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) one_and_half = zero + timedelta(seconds=1.5) for entity_id in states: if entity_id == "media_player.test": states[entity_id] = states[entity_id][1:] for state in states[entity_id]: - if state.last_changed == one: + # If the state is recorded before the start time + # start it will have its last_updated and last_changed + # set to the start time. + if state.last_updated < one_and_half: + state.last_updated = one_and_half state.last_changed = one_and_half hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=True, + hass, one_and_half, four, include_start_time_state=True, entity_ids=list(states) ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -593,6 +614,7 @@ def test_get_significant_states_without_initial( one_and_half, four, include_start_time_state=False, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -698,7 +720,12 @@ def test_get_significant_states_only( # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) - hist = history.get_significant_states(hass, start, significant_changes_only=True) + hist = history.get_significant_states( + hass, + start, + significant_changes_only=True, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 2 assert not any( @@ -711,7 +738,12 @@ def test_get_significant_states_only( state.last_updated == states[2].last_updated for state in hist[entity_id] ) - hist = history.get_significant_states(hass, start, significant_changes_only=False) + hist = history.get_significant_states( + hass, + start, + significant_changes_only=False, + entity_ids=list({state.entity_id for state in states}), + ) assert len(hist[entity_id]) == 3 assert_multiple_states_equal_without_context_and_last_changed( @@ -737,7 +769,11 @@ async def test_get_significant_states_only_minimal_response( await async_wait_recording_done(hass) hist = history.get_significant_states( - hass, now, minimal_response=True, significant_changes_only=False + hass, + now, + minimal_response=True, + significant_changes_only=False, + entity_ids=["sensor.test"], ) assert len(hist["sensor.test"]) == 3 @@ -868,6 +904,7 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( conn.commit() with patch.object(instance, "schema_version", 24): + instance.states_meta_manager.active = False no_attributes = True hist = history.state_changes_during_period( hass, @@ -909,9 +946,8 @@ async def test_get_states_query_during_migration_to_schema_25( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) entity_id = "light.test" - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, [entity_id] - ) + await instance.async_add_executor_job(_add_db_entries, hass, point, [entity_id]) + assert instance.states_meta_manager.active no_attributes = True hist = await _async_get_states(hass, end, [entity_id], no_attributes=no_attributes) @@ -929,6 +965,7 @@ async def test_get_states_query_during_migration_to_schema_25( conn.commit() with patch.object(instance, "schema_version", 24): + instance.states_meta_manager.active = False no_attributes = True hist = await _async_get_states( hass, end, [entity_id], no_attributes=no_attributes @@ -963,9 +1000,8 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( entity_id_2 = "switch.test" entity_ids = [entity_id_1, entity_id_2] - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, entity_ids - ) + await instance.async_add_executor_job(_add_db_entries, hass, point, entity_ids) + assert instance.states_meta_manager.active no_attributes = True hist = await _async_get_states(hass, end, entity_ids, no_attributes=no_attributes) @@ -983,6 +1019,7 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( conn.commit() with patch.object(instance, "schema_version", 24): + instance.states_meta_manager.active = False no_attributes = True hist = await _async_get_states( hass, end, entity_ids, no_attributes=no_attributes @@ -1017,7 +1054,7 @@ async def test_get_full_significant_states_handles_empty_last_changed( await async_wait_recording_done(hass) def _get_entries(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return history.get_full_significant_states_with_session( hass, session, @@ -1035,7 +1072,7 @@ async def test_get_full_significant_states_handles_empty_last_changed( assert sensor_one_states[0].last_updated != sensor_one_states[1].last_updated def _fetch_native_states() -> list[State]: - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: native_states = [] db_state_attributes = { state_attributes.attributes_id: state_attributes @@ -1071,7 +1108,7 @@ async def test_get_full_significant_states_handles_empty_last_changed( ) def _fetch_db_states() -> list[States]: - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = list(session.query(States)) session.expunge_all() return states @@ -1113,18 +1150,10 @@ def test_state_changes_during_period_multiple_entities_single_test( wait_recording_done(hass) end = dt_util.utcnow() - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value - for entity_id, value in test_entites.items(): hist = history.state_changes_during_period(hass, start, end, entity_id) assert len(hist) == 1 - hist[entity_id][0].state == value - - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + assert hist[entity_id][0].state == value @pytest.mark.freeze_time("2039-01-19 03:14:07.555555-00:00") @@ -1145,7 +1174,7 @@ async def test_get_full_significant_states_past_year_2038( await async_wait_recording_done(hass) def _get_entries(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return history.get_full_significant_states_with_session( hass, session, @@ -1161,3 +1190,63 @@ async def test_get_full_significant_states_past_year_2038( assert_states_equal_without_context(sensor_one_states[1], state1) assert sensor_one_states[0].last_changed == past_2038_time assert sensor_one_states[0].last_updated == past_2038_time + + +def test_get_significant_states_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for get_significant_states.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_ids must be provided"): + history.get_significant_states(hass, now, None) + + +def test_state_changes_during_period_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for state_changes_during_period.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_id must be provided"): + history.state_changes_during_period(hass, now, None) + + +def test_get_significant_states_with_filters_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test passing filters is no longer supported.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(NotImplementedError, match="Filters are no longer supported"): + history.get_significant_states( + hass, now, None, ["media_player.test"], Filters() + ) + + +def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_significant_states returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} + + +def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test state_changes_during_period returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert ( + history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} + ) + + +def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_last_state_changes returns an empty dict when entities not in the db.""" + hass = hass_recorder() + assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index ef5ec233cf3..b04d172487c 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -6,17 +6,14 @@ from collections.abc import Callable # pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta -import importlib import json -import sys from unittest.mock import patch, sentinel import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import Session from homeassistant.components import recorder -from homeassistant.components.recorder import core, history, statistics +from homeassistant.components.recorder import history +from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State @@ -28,56 +25,15 @@ from .common import ( assert_multiple_states_equal_without_context, assert_multiple_states_equal_without_context_and_last_changed, assert_states_equal_without_context, + old_db_schema, wait_recording_done, ) -CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_30" - - -def _create_engine_test(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION - ) - ) - session.commit() - return engine - @pytest.fixture(autouse=True) def db_schema_30(): - """Fixture to initialize the db with the old schema.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( - core, "EventTypes", old_db_schema.EventTypes - ), patch.object( - core, "EventData", old_db_schema.EventData - ), patch.object( - core, "States", old_db_schema.States - ), patch.object( - core, "Events", old_db_schema.Events - ), patch.object( - core, "StateAttributes", old_db_schema.StateAttributes - ), patch( - CREATE_ENGINE_TARGET, new=_create_engine_test - ): + """Fixture to initialize the db with the old schema 30.""" + with old_db_schema("30"): yield @@ -357,7 +313,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -376,7 +332,9 @@ def test_get_significant_states_minimal_response( instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four, minimal_response=True) + hist = history.get_significant_states( + hass, zero, four, minimal_response=True, entity_ids=list(states) + ) entites_with_reducable_states = [ "media_player.test", "media_player.test3", @@ -460,6 +418,7 @@ def test_get_significant_states_with_initial( one_and_half, four, include_start_time_state=True, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -495,6 +454,7 @@ def test_get_significant_states_without_initial( one_and_half, four, include_start_time_state=False, + entity_ids=list(states), ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -613,7 +573,10 @@ def test_get_significant_states_only( states.append(set_state("412", attributes={"attribute": 54.23})) hist = history.get_significant_states( - hass, start, significant_changes_only=True + hass, + start, + significant_changes_only=True, + entity_ids=list({state.entity_id for state in states}), ) assert len(hist[entity_id]) == 2 @@ -628,7 +591,10 @@ def test_get_significant_states_only( ) hist = history.get_significant_states( - hass, start, significant_changes_only=False + hass, + start, + significant_changes_only=False, + entity_ids=list({state.entity_id for state in states}), ) assert len(hist[entity_id]) == 3 @@ -741,15 +707,67 @@ def test_state_changes_during_period_multiple_entities_single_test( wait_recording_done(hass) end = dt_util.utcnow() - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value - for entity_id, value in test_entites.items(): hist = history.state_changes_during_period(hass, start, end, entity_id) assert len(hist) == 1 - hist[entity_id][0].state == value + assert hist[entity_id][0].state == value - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + +def test_get_significant_states_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for get_significant_states.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_ids must be provided"): + history.get_significant_states(hass, now, None) + + +def test_state_changes_during_period_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for state_changes_during_period.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_id must be provided"): + history.state_changes_during_period(hass, now, None) + + +def test_get_significant_states_with_filters_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test passing filters is no longer supported.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(NotImplementedError, match="Filters are no longer supported"): + history.get_significant_states( + hass, now, None, ["media_player.test"], Filters() + ) + + +def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_significant_states returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} + + +def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test state_changes_during_period returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert ( + history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} + ) + + +def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_last_state_changes returns an empty dict when entities not in the db.""" + hass = hass_recorder() + assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py new file mode 100644 index 00000000000..abc80572c16 --- /dev/null +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -0,0 +1,764 @@ +"""The tests the History component.""" +from __future__ import annotations + +from collections.abc import Callable + +# pylint: disable=invalid-name +from copy import copy +from datetime import datetime, timedelta +import json +from unittest.mock import patch, sentinel + +import pytest + +from homeassistant.components import recorder +from homeassistant.components.recorder import history +from homeassistant.components.recorder.filters import Filters +from homeassistant.components.recorder.models import process_timestamp +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.json import JSONEncoder +import homeassistant.util.dt as dt_util + +from .common import ( + assert_dict_of_states_equal_without_context_and_last_changed, + assert_multiple_states_equal_without_context, + assert_multiple_states_equal_without_context_and_last_changed, + assert_states_equal_without_context, + old_db_schema, + wait_recording_done, +) + + +@pytest.fixture(autouse=True) +def db_schema_32(): + """Fixture to initialize the db with the old schema 32.""" + with old_db_schema("32"): + yield + + +def test_get_full_significant_states_with_session_entity_no_matches( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test getting states at a specific point in time for entities that never have been recorded.""" + hass = hass_recorder() + now = dt_util.utcnow() + time_before_recorder_ran = now - timedelta(days=1000) + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): + assert ( + history.get_full_significant_states_with_session( + hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] + ) + == {} + ) + assert ( + history.get_full_significant_states_with_session( + hass, + session, + time_before_recorder_ran, + now, + entity_ids=["demo.id", "demo.id2"], + ) + == {} + ) + + +def test_significant_states_with_session_entity_minimal_response_no_matches( + hass_recorder: Callable[..., HomeAssistant], +) -> None: + """Test getting states at a specific point in time for entities that never have been recorded.""" + hass = hass_recorder() + now = dt_util.utcnow() + time_before_recorder_ran = now - timedelta(days=1000) + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): + assert ( + history.get_significant_states_with_session( + hass, + session, + time_before_recorder_ran, + now, + entity_ids=["demo.id"], + minimal_response=True, + ) + == {} + ) + assert ( + history.get_significant_states_with_session( + hass, + session, + time_before_recorder_ran, + now, + entity_ids=["demo.id", "demo.id2"], + minimal_response=True, + ) + == {} + ) + + +@pytest.mark.parametrize( + ("attributes", "no_attributes", "limit"), + [ + ({"attr": True}, False, 5000), + ({}, True, 5000), + ({"attr": True}, False, 3), + ({}, True, 3), + ], +) +def test_state_changes_during_period( + hass_recorder: Callable[..., HomeAssistant], attributes, no_attributes, limit +) -> None: + """Test state change during period.""" + hass = hass_recorder() + entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, attributes) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [ + set_state("idle"), + set_state("Netflix"), + set_state("Plex"), + set_state("YouTube"), + ] + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") + + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes, limit=limit + ) + + assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) + + +def test_state_changes_during_period_descending( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test state change during period descending.""" + hass = hass_recorder() + entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, {"any": 1}) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=2) + point3 = start + timedelta(seconds=1, microseconds=3) + point4 = start + timedelta(seconds=1, microseconds=4) + end = point + timedelta(seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [set_state("idle")] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("Netflix")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 + ): + states.append(set_state("Plex")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 + ): + states.append(set_state("YouTube")) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") + + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=False + ) + assert_multiple_states_equal_without_context(states, hist[entity_id]) + + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=True + ) + assert_multiple_states_equal_without_context( + states, list(reversed(list(hist[entity_id]))) + ) + + +def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: + """Test number of state changes.""" + hass = hass_recorder() + entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") + + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states.append(set_state("2")) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) + + hist = history.get_last_state_changes(hass, 2, entity_id) + + assert_multiple_states_equal_without_context(states, hist[entity_id]) + + +def test_ensure_state_can_be_copied( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Ensure a state can pass though copy(). + + The filter integration uses copy() on states + from history. + """ + hass = hass_recorder() + entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") + + hist = history.get_last_state_changes(hass, 2, entity_id) + + assert_states_equal_without_context( + copy(hist[entity_id][0]), hist[entity_id][0] + ) + assert_states_equal_without_context( + copy(hist[entity_id][1]), hist[entity_id][1] + ) + + +def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + +def test_get_significant_states_minimal_response( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test that only significant states are returned. + + When minimal responses is set only the first and + last states return a complete state. + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states( + hass, zero, four, minimal_response=True, entity_ids=list(states) + ) + entites_with_reducable_states = [ + "media_player.test", + "media_player.test3", + ] + + # All states for media_player.test state are reduced + # down to last_changed and state when minimal_response + # is set except for the first state. + # is set. We use JSONEncoder to make sure that are + # pre-encoded last_changed is always the same as what + # will happen with encoding a native state + for entity_id in entites_with_reducable_states: + entity_states = states[entity_id] + for state_idx in range(1, len(entity_states)): + input_state = entity_states[state_idx] + orig_last_changed = orig_last_changed = json.dumps( + process_timestamp(input_state.last_changed), + cls=JSONEncoder, + ).replace('"', "") + orig_state = input_state.state + entity_states[state_idx] = { + "last_changed": orig_last_changed, + "state": orig_state, + } + + assert len(hist) == len(states) + assert_states_equal_without_context( + states["media_player.test"][0], hist["media_player.test"][0] + ) + assert states["media_player.test"][1] == hist["media_player.test"][1] + assert states["media_player.test"][2] == hist["media_player.test"][2] + + assert_multiple_states_equal_without_context( + states["media_player.test2"], hist["media_player.test2"] + ) + assert_states_equal_without_context( + states["media_player.test3"][0], hist["media_player.test3"][0] + ) + assert states["media_player.test3"][1] == hist["media_player.test3"][1] + + assert_multiple_states_equal_without_context( + states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test2"], hist["thermostat.test2"] + ) + + +@pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) +def test_get_significant_states_with_initial( + time_zone, hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + hass = hass_recorder() + hass.config.set_time_zone(time_zone) + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + if entity_id == "media_player.test": + states[entity_id] = states[entity_id][1:] + for state in states[entity_id]: + if state.last_changed == one: + state.last_changed = one_and_half + + hist = history.get_significant_states( + hass, one_and_half, four, include_start_time_state=True, entity_ids=list(states) + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + +def test_get_significant_states_without_initial( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test that only significant states are returned. + + We should get back every thermostat change that + includes an attribute change, but only the state updates for + media player (attribute changes are not significant and not returned). + """ + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + states[entity_id] = list( + filter( + lambda s: s.last_changed != one + and s.last_changed != one_with_microsecond, + states[entity_id], + ) + ) + del states["media_player.test2"] + + hist = history.get_significant_states( + hass, + one_and_half, + four, + include_start_time_state=False, + entity_ids=list(states), + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + +def test_get_significant_states_entity_id( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test that only significant states are returned for one entity.""" + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] + + hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + +def test_get_significant_states_multiple_entity_ids( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test that only significant states are returned for one entity.""" + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] + + hist = history.get_significant_states( + hass, + zero, + four, + ["media_player.test", "thermostat.test"], + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["media_player.test"], hist["media_player.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) + + +def test_get_significant_states_are_ordered( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test order of results from get_significant_states. + + When entity ids are given, the results should be returned with the data + in the same order. + """ + hass = hass_recorder() + + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, _states = record_states(hass) + entity_ids = ["media_player.test", "media_player.test2"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids + entity_ids = ["media_player.test2", "media_player.test"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids + + +def test_get_significant_states_only( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test significant states when significant_states_only is set.""" + hass = hass_recorder() + entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + + def set_state(state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=4) + points = [] + for i in range(1, 4): + points.append(start + timedelta(minutes=i)) + + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("123", attributes={"attribute": 10.64}) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=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], + ): + # 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], + ): + # everything is different + states.append(set_state("412", attributes={"attribute": 54.23})) + + hist = history.get_significant_states( + hass, + start, + significant_changes_only=True, + entity_ids=list({state.entity_id for state in states}), + ) + + assert len(hist[entity_id]) == 2 + assert not any( + state.last_updated == states[0].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[1].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[2].last_updated for state in hist[entity_id] + ) + + hist = history.get_significant_states( + hass, + start, + significant_changes_only=False, + entity_ids=list({state.entity_id for state in states}), + ) + + assert len(hist[entity_id]) == 3 + assert_multiple_states_equal_without_context_and_last_changed( + states, hist[entity_id] + ) + + +def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: + """Record some test states. + + We inject a bunch of state updates from media player, zone and + thermostat. + """ + mp = "media_player.test" + mp2 = "media_player.test2" + mp3 = "media_player.test3" + therm = "thermostat.test" + therm2 = "thermostat.test2" + zone = "zone.home" + script_c = "script.can_cancel_this_one" + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + zero = dt_util.utcnow() + one = zero + timedelta(seconds=1) + two = one + timedelta(seconds=1) + three = two + timedelta(seconds=1) + 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 + ): + states[mp].append( + set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) + ) + states[mp2].append( + set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)}) + ) + states[mp3].append( + set_state(mp3, "idle", attributes={"media_title": str(sentinel.mt1)}) + ) + states[therm].append( + set_state(therm, 20, attributes={"current_temperature": 19.5}) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=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 + ): + # 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 + set_state(zone, "zoning") + states[script_c].append( + set_state(script_c, "off", attributes={"can_cancel": True}) + ) + states[therm].append( + set_state(therm, 21, attributes={"current_temperature": 19.8}) + ) + states[therm2].append( + set_state(therm2, 20, attributes={"current_temperature": 19}) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[mp].append( + set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) + ) + states[mp3].append( + set_state(mp3, "Netflix", attributes={"media_title": str(sentinel.mt3)}) + ) + # Attributes changed even though state is the same + states[therm].append( + set_state(therm, 21, attributes={"current_temperature": 20}) + ) + + return zero, four, states + + +def test_state_changes_during_period_multiple_entities_single_test( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test state change during period with multiple entities in the same test. + + This test ensures the sqlalchemy query cache does not + generate incorrect results. + """ + hass = hass_recorder() + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + start = dt_util.utcnow() + test_entites = {f"sensor.{i}": str(i) for i in range(30)} + for entity_id, value in test_entites.items(): + hass.states.set(entity_id, value) + + wait_recording_done(hass) + end = dt_util.utcnow() + + for entity_id, value in test_entites.items(): + hist = history.state_changes_during_period(hass, start, end, entity_id) + assert len(hist) == 1 + assert hist[entity_id][0].state == value + + +def test_get_significant_states_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for get_significant_states.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_ids must be provided"): + history.get_significant_states(hass, now, None) + + +def test_state_changes_during_period_without_entity_ids_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test at least one entity id is required for state_changes_during_period.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(ValueError, match="entity_id must be provided"): + history.state_changes_during_period(hass, now, None) + + +def test_get_significant_states_with_filters_raises( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test passing filters is no longer supported.""" + hass = hass_recorder() + now = dt_util.utcnow() + with pytest.raises(NotImplementedError, match="Filters are no longer supported"): + history.get_significant_states( + hass, now, None, ["media_player.test"], Filters() + ) + + +def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_significant_states returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} + + +def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test state_changes_during_period returns an empty dict when entities not in the db.""" + hass = hass_recorder() + now = dt_util.utcnow() + assert ( + history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} + ) + + +def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test get_last_state_changes returns an empty dict when entities not in the db.""" + hass = hass_recorder() + assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 337aced4880..84dbb9a2816 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -8,10 +8,9 @@ from pathlib import Path import sqlite3 import threading from typing import cast -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory -import py import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError @@ -28,6 +27,7 @@ from homeassistant.components.recorder import ( SQLITE_URL_PREFIX, Recorder, get_instance, + migration, pool, statistics, ) @@ -59,6 +59,7 @@ from homeassistant.components.recorder.services import ( from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( EVENT_COMPONENT_LOADED, + EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -75,6 +76,7 @@ from homeassistant.util.json import json_loads from .common import ( async_block_recorder, async_wait_recording_done, + convert_pending_states_to_meta, corrupt_db_file, run_information_with_session, wait_recording_done, @@ -133,7 +135,7 @@ async def test_shutdown_before_startup_finishes( session = await hass.async_add_executor_job(instance.get_session) with patch.object(instance, "engine"): - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await hass.async_block_till_done() await hass.async_stop() @@ -187,12 +189,12 @@ async def test_shutdown_closes_connections( pool.shutdown = Mock() def _ensure_connected(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: list(session.query(States)) await instance.async_add_executor_job(_ensure_connected) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await hass.async_block_till_done() assert len(pool.shutdown.mock_calls) == 1 @@ -221,7 +223,7 @@ async def test_state_gets_saved_when_set_before_start_event( await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -237,7 +239,7 @@ async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> Non await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = [] for db_state, db_state_attributes, states_meta in ( session.query(States, StateAttributes, StatesMeta) @@ -278,7 +280,7 @@ async def test_saving_state_with_nul( hass.states.async_set(entity_id, state, attributes) await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = [] for db_state, db_state_attributes, states_meta in ( session.query(States, StateAttributes, StatesMeta) @@ -321,7 +323,7 @@ async def test_saving_many_states( assert expire_all.called - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 6 assert db_states[0].event_id is None @@ -345,7 +347,7 @@ async def test_saving_state_with_intermixed_time_changes( await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 2 assert db_states[0].event_id is None @@ -385,7 +387,7 @@ def test_saving_state_with_exception( hass.states.set(entity_id, state, attributes) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) >= 1 @@ -426,7 +428,7 @@ def test_saving_state_with_sqlalchemy_exception( hass.states.set(entity_id, state, attributes) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) >= 1 @@ -448,7 +450,7 @@ async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( await async_wait_recording_done(hass) - with patch.object(instance, "db_retry_wait", 0.05), patch.object( + with patch.object(instance, "db_retry_wait", 0.01), patch.object( instance.event_session, "flush", side_effect=OperationalError( @@ -459,8 +461,8 @@ async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( hass.states.async_set(entity_id, "on", attributes) hass.states.async_set(entity_id, "off", attributes) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() assert "Error executing query" in caplog.text @@ -494,7 +496,7 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: get_instance(hass).block_till_done() events: list[Event] = [] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: for select_event, event_data, event_types in ( session.query(Events, EventData, EventTypes) .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) @@ -537,7 +539,7 @@ def test_saving_state_with_commit_interval_zero( wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -563,6 +565,7 @@ def _add_entities(hass, entity_ids): native_state = db_state.to_native() native_state.attributes = db_state_attributes.to_native() states.append(native_state) + convert_pending_states_to_meta(get_instance(hass), session) return states @@ -647,7 +650,7 @@ async def test_saving_event_exclude_event_type( await async_wait_recording_done(hass) def _get_events(hass: HomeAssistant, event_types: list[str]) -> list[Event]: - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: events = [] for event, event_data, event_types in ( session.query(Events, EventData, EventTypes) @@ -777,7 +780,7 @@ def test_saving_state_and_removing_entity( wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = list( session.query(StatesMeta.entity_id, States.state) .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) @@ -804,7 +807,7 @@ def test_saving_state_with_oversized_attributes( wait_recording_done(hass) states = [] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: for db_state, db_state_attributes, states_meta in ( session.query(States, StateAttributes, StatesMeta) .outerjoin( @@ -838,7 +841,7 @@ def test_saving_event_with_oversized_data( wait_recording_done(hass) events = {} - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: for _, data, event_type in ( session.query(Events.event_id, EventData.shared_data, EventTypes.event_type) .outerjoin(EventData, Events.data_id == EventData.data_id) @@ -864,7 +867,7 @@ def test_saving_event_invalid_context_ulid( wait_recording_done(hass) events = {} - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: for _, data, event_type in ( session.query(Events.event_id, EventData.shared_data, EventTypes.event_type) .outerjoin(EventData, Events.data_id == EventData.data_id) @@ -1267,7 +1270,7 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: statistics_runs = list(session.query(StatisticsRuns)) assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) @@ -1278,11 +1281,13 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") def test_compile_missing_statistics( - tmpdir: py.path.local, freezer: FrozenDateTimeFactory + tmp_path: Path, freezer: FrozenDateTimeFactory ) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" hass = get_test_home_assistant() @@ -1292,7 +1297,7 @@ def test_compile_missing_statistics( wait_recording_done(hass) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: statistics_runs = list(session.query(StatisticsRuns)) assert len(statistics_runs) == 1 last_run = process_timestamp(statistics_runs[0].start) @@ -1330,7 +1335,7 @@ def test_compile_missing_statistics( wait_recording_done(hass) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: statistics_runs = list(session.query(StatisticsRuns)) assert len(statistics_runs) == 13 # 12 5-minute runs last_run = process_timestamp(statistics_runs[1].start) @@ -1355,7 +1360,7 @@ def test_saving_sets_old_state(hass_recorder: Callable[..., HomeAssistant]) -> N hass.states.set("test.two", "s4", {}) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = list( session.query( StatesMeta.entity_id, States.state_id, States.old_state_id, States.state @@ -1389,7 +1394,7 @@ def test_saving_state_with_serializable_data( hass.states.set("test.two", "s3", {}) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = list( session.query( StatesMeta.entity_id, States.state_id, States.old_state_id, States.state @@ -1447,7 +1452,7 @@ def test_service_disable_events_not_recording( assert len(events) == 1 event = events[0] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_events = list( session.query(Events) .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) @@ -1471,7 +1476,7 @@ def test_service_disable_events_not_recording( assert events[0].data != events[1].data db_events = [] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: for select_event, event_data, event_types in ( session.query(Events, EventData, EventTypes) .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) @@ -1515,7 +1520,7 @@ def test_service_disable_states_not_recording( hass.states.set("test.one", "on", {}) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: assert len(list(session.query(States))) == 0 assert hass.services.call( @@ -1528,7 +1533,7 @@ def test_service_disable_states_not_recording( hass.states.set("test.two", "off", {}) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 1 assert db_states[0].event_id is None @@ -1539,9 +1544,11 @@ def test_service_disable_states_not_recording( ) -def test_service_disable_run_information_recorded(tmpdir: py.path.local) -> None: +def test_service_disable_run_information_recorded(tmp_path: Path) -> None: """Test that runs are still recorded when recorder is disabled.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" hass = get_test_home_assistant() @@ -1550,7 +1557,7 @@ def test_service_disable_run_information_recorded(tmpdir: py.path.local) -> None hass.start() wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_run_info = list(session.query(RecorderRuns)) assert len(db_run_info) == 1 assert db_run_info[0].start is not None @@ -1572,7 +1579,7 @@ def test_service_disable_run_information_recorded(tmpdir: py.path.local) -> None hass.start() wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_run_info = list(session.query(RecorderRuns)) assert len(db_run_info) == 2 assert db_run_info[0].start is not None @@ -1588,12 +1595,14 @@ class CannotSerializeMe: async def test_database_corruption_while_running( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover from sqlite3 db corruption.""" - def _create_tmpdir_for_test_db(): - return tmpdir.mkdir("sqlite").join("test.db") + def _create_tmpdir_for_test_db() -> Path: + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + return test_dir.joinpath("test.db") test_db_file = await hass.async_add_executor_job(_create_tmpdir_for_test_db) dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -1642,7 +1651,7 @@ async def test_database_corruption_while_running( await async_wait_recording_done(hass) def _get_last_state(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) assert len(db_states) == 1 db_states[0].entity_id = "test.two" @@ -1680,7 +1689,7 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: hass.bus.fire("hello", data) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_events = list( session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(event_types)) @@ -1695,7 +1704,7 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: hass.bus.fire("hello", data) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_events = list( session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(event_types)) @@ -1729,7 +1738,7 @@ async def test_database_lock_and_unlock( event_types = (event_type,) def _get_db_events(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return list( session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(event_types)) @@ -1748,7 +1757,7 @@ async def test_database_lock_and_unlock( # Recording can't be finished while lock is held with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(asyncio.shield(task), timeout=1) + await asyncio.wait_for(asyncio.shield(task), timeout=0.25) db_events = await hass.async_add_executor_job(_get_db_events) assert len(db_events) == 0 @@ -1764,11 +1773,12 @@ async def test_database_lock_and_overflow( hass: HomeAssistant, recorder_db_url: str, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: """Test writing events during lock leading to overflow the queue causes the database to unlock.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): # Database locking is only used for SQLite - return + return pytest.skip("Database locking is only used for SQLite") # Use file DB, in memory DB cannot do write locks. if recorder_db_url == "sqlite://": @@ -1778,39 +1788,119 @@ async def test_database_lock_and_overflow( recorder.CONF_COMMIT_INTERVAL: 0, recorder.CONF_DB_URL: recorder_db_url, } - await async_setup_recorder_instance(hass, config) - await hass.async_block_till_done() - event_type = "EVENT_TEST" - event_types = (event_type,) def _get_db_events(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return list( session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(event_types)) ) ) - instance = get_instance(hass) + with patch.object(recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1), patch.object( + recorder.core, "DB_LOCK_QUEUE_CHECK_TIMEOUT", 0.01 + ), patch.object(recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 0): + await async_setup_recorder_instance(hass, config) + await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) + + instance = get_instance(hass) - with patch.object(recorder.core, "MAX_QUEUE_BACKLOG", 1), patch.object( - recorder.core, "DB_LOCK_QUEUE_CHECK_TIMEOUT", 0.1 - ): await instance.lock_database() event_data = {"test_attr": 5, "test_attr_10": "nice"} - hass.bus.fire(event_type, event_data) + hass.bus.async_fire(event_type, event_data) # Check that this causes the queue to overflow and write succeeds # even before unlocking. await async_wait_recording_done(hass) - db_events = await hass.async_add_executor_job(_get_db_events) + db_events = await instance.async_add_executor_job(_get_db_events) assert len(db_events) == 1 + assert "Database queue backlog reached more than" in caplog.text assert not instance.unlock_database() +async def test_database_lock_and_overflow_checks_available_memory( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test writing events during lock leading to overflow the queue causes the database to unlock.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + return pytest.skip("Database locking is only used for SQLite") + + # Use file DB, in memory DB cannot do write locks. + if recorder_db_url == "sqlite://": + # Use file DB, in memory DB cannot do write locks. + recorder_db_url = "sqlite:///" + str(tmp_path / "pytest.db") + config = { + recorder.CONF_COMMIT_INTERVAL: 0, + recorder.CONF_DB_URL: recorder_db_url, + } + + def _get_db_events(): + with session_scope(hass=hass, read_only=True) as session: + return list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) + + await async_setup_recorder_instance(hass, config) + await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) + await async_wait_recording_done(hass) + + with patch.object(recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1), patch.object( + recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 1 + ), patch.object(recorder.core, "DB_LOCK_QUEUE_CHECK_TIMEOUT", 0.01), patch.object( + recorder.core.Recorder, + "_available_memory", + return_value=recorder.core.ESTIMATED_QUEUE_ITEM_SIZE * 4, + ): + instance = get_instance(hass) + + await instance.lock_database() + + # Record up to the extended limit (which takes into account the available memory) + for _ in range(2): + event_data = {"test_attr": 5, "test_attr_10": "nice"} + hass.bus.async_fire(event_type, event_data) + + def _wait_database_unlocked(): + return instance._database_lock_task.database_unlock.wait(0.2) + + databack_unlocked = await hass.async_add_executor_job(_wait_database_unlocked) + assert not databack_unlocked + + db_events = await instance.async_add_executor_job(_get_db_events) + assert len(db_events) == 0 + + assert "Database queue backlog reached more than" not in caplog.text + + # Record beyond the extended limit (which takes into account the available memory) + for _ in range(20): + event_data = {"test_attr": 5, "test_attr_10": "nice"} + hass.bus.async_fire(event_type, event_data) + + # Check that this causes the queue to overflow and write succeeds + # even before unlocking. + await async_wait_recording_done(hass) + + assert not instance.unlock_database() + + assert "Database queue backlog reached more than" in caplog.text + + db_events = await instance.async_add_executor_job(_get_db_events) + assert len(db_events) >= 2 + + async def test_database_lock_timeout( recorder_mock: Recorder, hass: HomeAssistant, recorder_db_url: str ) -> None: @@ -1919,7 +2009,7 @@ def test_deduplication_event_data_inside_commit_interval( hass.bus.fire("this_event", {"de": "dupe"}) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: event_types = ("this_event",) events = list( session.query(Events) @@ -1960,7 +2050,7 @@ def test_deduplication_state_attributes_inside_commit_interval( hass.states.set(entity_id, "off", attributes) wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = list( session.query(States).outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) @@ -1986,7 +2076,7 @@ async def test_async_block_till_done( hass.states.async_set(entity_id, "off", attributes) def _fetch_states(): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return list(session.query(States)) await async_block_recorder(hass, 0.1) @@ -2194,7 +2284,7 @@ async def test_excluding_attributes_by_integration( await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = [] for db_state, db_state_attributes, states_meta in ( session.query(States, StateAttributes, StatesMeta) @@ -2232,3 +2322,136 @@ async def test_lru_increases_with_many_entities( == mock_entity_count * 2 ) assert recorder_mock.states_meta_manager._id_map.get_size() == mock_entity_count * 2 + + +async def test_clean_shutdown_when_recorder_thread_raises_during_initialize_database( + hass: HomeAssistant, +) -> None: + """Test we still shutdown cleanly when the recorder thread raises during initialize_database.""" + with patch.object(migration, "initialize_database", side_effect=Exception), patch( + "homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True + ): + if recorder.DOMAIN not in hass.data: + recorder_helper.async_initialize_recorder(hass) + assert not await async_setup_component( + hass, + recorder.DOMAIN, + { + recorder.DOMAIN: { + CONF_DB_URL: "sqlite://", + CONF_DB_RETRY_WAIT: 0, + CONF_DB_MAX_RETRIES: 1, + } + }, + ) + await hass.async_block_till_done() + + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + + +async def test_clean_shutdown_when_recorder_thread_raises_during_validate_db_schema( + hass: HomeAssistant, +) -> None: + """Test we still shutdown cleanly when the recorder thread raises during validate_db_schema.""" + with patch.object(migration, "validate_db_schema", side_effect=Exception), patch( + "homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True + ): + if recorder.DOMAIN not in hass.data: + recorder_helper.async_initialize_recorder(hass) + assert not await async_setup_component( + hass, + recorder.DOMAIN, + { + recorder.DOMAIN: { + CONF_DB_URL: "sqlite://", + CONF_DB_RETRY_WAIT: 0, + CONF_DB_MAX_RETRIES: 1, + } + }, + ) + await hass.async_block_till_done() + + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + + +async def test_clean_shutdown_when_schema_migration_fails(hass: HomeAssistant) -> None: + """Test we still shutdown cleanly when schema migration fails.""" + with patch.object( + migration, + "validate_db_schema", + return_value=MagicMock(valid=False, current_version=1), + ), patch( + "homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True + ), patch.object( + migration, + "migrate_schema", + side_effect=Exception, + ): + if recorder.DOMAIN not in hass.data: + recorder_helper.async_initialize_recorder(hass) + assert await async_setup_component( + hass, + recorder.DOMAIN, + { + recorder.DOMAIN: { + CONF_DB_URL: "sqlite://", + CONF_DB_RETRY_WAIT: 0, + CONF_DB_MAX_RETRIES: 1, + } + }, + ) + await hass.async_block_till_done() + + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + + +async def test_events_are_recorded_until_final_write( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test that events are recorded until the final write.""" + instance = await async_setup_recorder_instance(hass, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + hass.bus.async_fire("fake_event") + await async_wait_recording_done(hass) + + def get_events() -> list[Event]: + events: list[Event] = [] + with session_scope(hass=hass, read_only=True) as session: + for select_event, event_types in ( + session.query(Events, EventTypes) + .filter( + Events.event_type_id.in_( + select_event_type_ids(("fake_event", "after_final_write")) + ) + ) + .outerjoin( + EventTypes, (Events.event_type_id == EventTypes.event_type_id) + ) + ): + select_event = cast(Events, select_event) + event_types = cast(EventTypes, event_types) + + native_event = select_event.to_native() + native_event.event_type = event_types.event_type + events.append(native_event) + + return events + + events = await instance.async_add_executor_job(get_events) + assert len(events) == 1 + db_event = events[0] + assert db_event.event_type == "fake_event" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + + assert not instance.engine diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index c9e49697585..ede5bc32a6f 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -24,37 +24,23 @@ from homeassistant.components import persistent_notification as pn, recorder from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, - Events, - EventTypes, RecorderRuns, States, - StatesMeta, -) -from homeassistant.components.recorder.queries import select_event_type_ids -from homeassistant.components.recorder.tasks import ( - EntityIDMigrationTask, - EntityIDPostMigrationTask, - EventTypeIDMigrationTask, ) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper import homeassistant.util.dt as dt_util -from .common import ( - async_recorder_block_till_done, - async_wait_recording_done, - create_engine_test, -) +from .common import async_wait_recording_done, create_engine_test from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator ORIG_TZ = dt_util.DEFAULT_TIME_ZONE def _get_native_states(hass, entity_id): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) metadata_id = instance.states_meta_manager.get(entity_id, session, True) states = [] @@ -273,7 +259,9 @@ async def test_events_during_migration_queue_exhausted( with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( "homeassistant.components.recorder.core.create_engine", new=create_engine_test, - ), patch.object(recorder.core, "MAX_QUEUE_BACKLOG", 1): + ), patch.object(recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1), patch.object( + recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 0 + ): recorder_helper.async_initialize_recorder(hass) await async_setup_component( hass, @@ -360,7 +348,7 @@ async def test_schema_migrate( # Check and report the outcome of the migration; if migration fails # the recorder will silently create a new database. - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: res = ( session.query(db_schema.SchemaChanges) .order_by(db_schema.SchemaChanges.change_id.desc()) @@ -597,338 +585,3 @@ def test_raise_if_exception_missing_empty_cause_str() -> None: with pytest.raises(ProgrammingError): migration.raise_if_exception_missing_str(programming_exc, ["not present"]) - - -@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -async def test_migrate_event_type_ids( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test we can migrate event_types to the EventTypes table.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - def _insert_events(): - with session_scope(hass=hass) as session: - session.add_all( - ( - Events( - event_type="event_type_one", - origin_idx=0, - time_fired_ts=1677721632.452529, - ), - Events( - event_type="event_type_one", - origin_idx=0, - time_fired_ts=1677721632.552529, - ), - Events( - event_type="event_type_two", - origin_idx=0, - time_fired_ts=1677721632.552529, - ), - ) - ) - - await instance.async_add_executor_job(_insert_events) - - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) - - def _fetch_migrated_events(): - with session_scope(hass=hass) as session: - events = ( - session.query(Events.event_id, Events.time_fired, EventTypes.event_type) - .filter( - Events.event_type_id.in_( - select_event_type_ids( - ( - "event_type_one", - "event_type_two", - ) - ) - ) - ) - .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) - .all() - ) - assert len(events) == 3 - result = {} - for event in events: - result.setdefault(event.event_type, []).append( - { - "event_id": event.event_id, - "time_fired": event.time_fired, - "event_type": event.event_type, - } - ) - return result - - events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type["event_type_two"]) == 1 - - -@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -async def test_migrate_entity_ids( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test we can migrate entity_ids to the StatesMeta table.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - def _insert_states(): - with session_scope(hass=hass) as session: - session.add_all( - ( - States( - entity_id="sensor.one", - state="one_1", - last_updated_ts=1.452529, - ), - States( - entity_id="sensor.two", - state="two_2", - last_updated_ts=2.252529, - ), - States( - entity_id="sensor.two", - state="two_1", - last_updated_ts=3.152529, - ), - ) - ) - - await instance.async_add_executor_job(_insert_states) - - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) - - def _fetch_migrated_states(): - with session_scope(hass=hass) as session: - states = ( - session.query( - States.state, - States.metadata_id, - States.last_updated_ts, - StatesMeta.entity_id, - ) - .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) - .all() - ) - assert len(states) == 3 - result = {} - for state in states: - result.setdefault(state.entity_id, []).append( - { - "state_id": state.entity_id, - "last_updated_ts": state.last_updated_ts, - "state": state.state, - } - ) - return result - - states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) - assert len(states_by_entity_id["sensor.two"]) == 2 - assert len(states_by_entity_id["sensor.one"]) == 1 - - -@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -async def test_post_migrate_entity_ids( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test we can migrate entity_ids to the StatesMeta table.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - def _insert_events(): - with session_scope(hass=hass) as session: - session.add_all( - ( - States( - entity_id="sensor.one", - state="one_1", - last_updated_ts=1.452529, - ), - States( - entity_id="sensor.two", - state="two_2", - last_updated_ts=2.252529, - ), - States( - entity_id="sensor.two", - state="two_1", - last_updated_ts=3.152529, - ), - ) - ) - - await instance.async_add_executor_job(_insert_events) - - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - instance.queue_task(EntityIDPostMigrationTask()) - await async_recorder_block_till_done(hass) - - def _fetch_migrated_states(): - with session_scope(hass=hass) as session: - states = session.query( - States.state, - States.entity_id, - ).all() - assert len(states) == 3 - return {state.state: state.entity_id for state in states} - - states_by_state = await instance.async_add_executor_job(_fetch_migrated_states) - assert states_by_state["one_1"] is None - assert states_by_state["two_2"] is None - assert states_by_state["two_1"] is None - - -@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -async def test_migrate_null_entity_ids( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test we can migrate entity_ids to the StatesMeta table.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - def _insert_states(): - with session_scope(hass=hass) as session: - session.add( - States( - entity_id="sensor.one", - state="one_1", - last_updated_ts=1.452529, - ), - ) - session.add_all( - States( - entity_id=None, - state="empty", - last_updated_ts=time + 1.452529, - ) - for time in range(1000) - ) - session.add( - States( - entity_id="sensor.one", - state="one_1", - last_updated_ts=2.452529, - ), - ) - - await instance.async_add_executor_job(_insert_states) - - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) - - def _fetch_migrated_states(): - with session_scope(hass=hass) as session: - states = ( - session.query( - States.state, - States.metadata_id, - States.last_updated_ts, - StatesMeta.entity_id, - ) - .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) - .all() - ) - assert len(states) == 1002 - result = {} - for state in states: - result.setdefault(state.entity_id, []).append( - { - "state_id": state.entity_id, - "last_updated_ts": state.last_updated_ts, - "state": state.state, - } - ) - return result - - states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) - assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 - assert len(states_by_entity_id["sensor.one"]) == 2 - - -@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -async def test_migrate_null_event_type_ids( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - def _insert_events(): - with session_scope(hass=hass) as session: - session.add( - Events( - event_type="event_type_one", - origin_idx=0, - time_fired_ts=1.452529, - ), - ) - session.add_all( - Events( - event_type=None, - origin_idx=0, - time_fired_ts=time + 1.452529, - ) - for time in range(1000) - ) - session.add( - Events( - event_type="event_type_one", - origin_idx=0, - time_fired_ts=2.452529, - ), - ) - - await instance.async_add_executor_job(_insert_events) - - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - - instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) - - def _fetch_migrated_events(): - with session_scope(hass=hass) as session: - events = ( - session.query(Events.event_id, Events.time_fired, EventTypes.event_type) - .filter( - Events.event_type_id.in_( - select_event_type_ids( - ( - "event_type_one", - migration._EMPTY_EVENT_TYPE, - ) - ) - ) - ) - .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) - .all() - ) - assert len(events) == 1002 - result = {} - for event in events: - result.setdefault(event.event_type, []).append( - { - "event_id": event.event_id, - "time_fired": event.time_fired, - "event_type": event.event_type, - } - ) - return result - - events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index f76cf318008..2ae32018213 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -553,6 +553,16 @@ async def test_migrate_event_type_ids( assert len(events_by_type["event_type_one"]) == 2 assert len(events_by_type["event_type_two"]) == 1 + def _get_many(): + with session_scope(hass=hass, read_only=True) as session: + return instance.event_type_manager.get_many( + ("event_type_one", "event_type_two"), session + ) + + mapped = await instance.async_add_executor_job(_get_many) + assert mapped["event_type_one"] is not None + assert mapped["event_type_two"] is not None + @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) async def test_migrate_entity_ids( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c5033481f23..f47f1d3e78b 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -269,20 +269,19 @@ async def test_lazy_state_handles_include_json( entity_id="sensor.invalid", shared_attrs="{INVALID_JSON}", ) - assert LazyState(row, {}, None).attributes == {} + assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {} assert "Error converting row to state attributes" in caplog.text -async def test_lazy_state_prefers_shared_attrs_over_attrs( +async def test_lazy_state_can_decode_attributes( caplog: pytest.LogCaptureFixture, ) -> None: - """Test that the LazyState prefers shared_attrs over attributes.""" + """Test that the LazyState prefers can decode attributes.""" row = PropertyMock( entity_id="sensor.invalid", - shared_attrs='{"shared":true}', - attributes='{"shared":false}', + attributes='{"shared":true}', ) - assert LazyState(row, {}, None).attributes == {"shared": True} + assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {"shared": True} async def test_lazy_state_handles_different_last_updated_and_last_changed( @@ -293,11 +292,11 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( row = PropertyMock( entity_id="sensor.valid", state="off", - shared_attrs='{"shared":true}', + attributes='{"shared":true}', last_updated_ts=now.timestamp(), last_changed_ts=(now - timedelta(seconds=60)).timestamp(), ) - lstate = LazyState(row, {}, None) + lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -324,11 +323,11 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( row = PropertyMock( entity_id="sensor.valid", state="off", - shared_attrs='{"shared":true}', + attributes='{"shared":true}', last_updated_ts=now.timestamp(), last_changed_ts=now.timestamp(), ) - lstate = LazyState(row, {}, None) + lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", diff --git a/tests/components/recorder/test_models_legacy.py b/tests/components/recorder/test_models_legacy.py new file mode 100644 index 00000000000..f830ac53544 --- /dev/null +++ b/tests/components/recorder/test_models_legacy.py @@ -0,0 +1,98 @@ +"""The tests for the Recorder component legacy models.""" +from datetime import datetime, timedelta +from unittest.mock import PropertyMock + +import pytest + +from homeassistant.components.recorder.models.legacy import LegacyLazyState +from homeassistant.util import dt as dt_util + + +async def test_legacy_lazy_state_prefers_shared_attrs_over_attrs( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the LazyState prefers shared_attrs over attributes.""" + row = PropertyMock( + entity_id="sensor.invalid", + shared_attrs='{"shared":true}', + attributes='{"shared":false}', + ) + assert LegacyLazyState(row, {}, None).attributes == {"shared": True} + + +async def test_legacy_lazy_state_handles_different_last_updated_and_last_changed( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the LazyState handles different last_updated and last_changed.""" + now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + row = PropertyMock( + entity_id="sensor.valid", + state="off", + shared_attrs='{"shared":true}', + last_updated_ts=now.timestamp(), + last_changed_ts=(now - timedelta(seconds=60)).timestamp(), + ) + lstate = LegacyLazyState(row, {}, None) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:03:01.000323+00:00", + "last_updated": "2021-06-12T03:04:01.000323+00:00", + "state": "off", + } + assert lstate.last_updated.timestamp() == row.last_updated_ts + assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:03:01.000323+00:00", + "last_updated": "2021-06-12T03:04:01.000323+00:00", + "state": "off", + } + + +async def test_legacy_lazy_state_handles_same_last_updated_and_last_changed( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the LazyState handles same last_updated and last_changed.""" + now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + row = PropertyMock( + entity_id="sensor.valid", + state="off", + shared_attrs='{"shared":true}', + last_updated_ts=now.timestamp(), + last_changed_ts=now.timestamp(), + ) + lstate = LegacyLazyState(row, {}, None) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:04:01.000323+00:00", + "last_updated": "2021-06-12T03:04:01.000323+00:00", + "state": "off", + } + assert lstate.last_updated.timestamp() == row.last_updated_ts + assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:04:01.000323+00:00", + "last_updated": "2021-06-12T03:04:01.000323+00:00", + "state": "off", + } + lstate.last_updated = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:04:01.000323+00:00", + "last_updated": "2020-06-12T03:04:01.000323+00:00", + "state": "off", + } + lstate.last_changed = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2020-06-12T03:04:01.000323+00:00", + "last_updated": "2020-06-12T03:04:01.000323+00:00", + "state": "off", + } diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 60620f39d69..04635acbcab 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,13 +10,12 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, migration +from homeassistant.components.recorder import purge, queries from homeassistant.components.recorder.const import ( SQLITE_MAX_BIND_VARS, SupportedDialect, ) from homeassistant.components.recorder.db_schema import ( - EventData, Events, EventTypes, RecorderRuns, @@ -37,18 +36,29 @@ from homeassistant.components.recorder.tasks import PurgeTask from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import json_dumps 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, async_wait_purge_done, async_wait_recording_done, + convert_pending_events_to_event_types, + convert_pending_states_to_meta, ) from tests.typing import RecorderInstanceGenerator +TEST_EVENT_TYPES = ( + "EVENT_TEST_AUTOPURGE", + "EVENT_TEST_PURGE", + "EVENT_TEST", + "EVENT_TEST_AUTOPURGE_WITH_EVENT_DATA", + "EVENT_TEST_PURGE_WITH_EVENT_DATA", + "EVENT_TEST_WITH_EVENT_DATA", +) + @pytest.fixture(name="use_sqlite") def mock_use_sqlite(request): @@ -253,7 +263,9 @@ async def test_purge_old_events( await _add_test_events(hass) with session_scope(hass=hass) as session: - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) assert events.count() == 6 purge_before = dt_util.utcnow() - timedelta(days=4) @@ -267,7 +279,8 @@ async def test_purge_old_events( states_batch_size=1, ) assert not finished - assert events.count() == 2 + all_events = events.all() + assert events.count() == 2, f"Should have 2 events left: {all_events}" # we should only have 2 events left finished = purge_old_data( @@ -377,7 +390,9 @@ async def test_purge_method( states = session.query(States) assert states.count() == 6 - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) assert events.count() == 6 statistics = session.query(StatisticsShortTerm) @@ -408,7 +423,9 @@ async def test_purge_method( with session_scope(hass=hass) as session: states = session.query(States) - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) statistics = session.query(StatisticsShortTerm) # only purged old states, events and statistics @@ -425,7 +442,9 @@ async def test_purge_method( with session_scope(hass=hass) as session: states = session.query(States) - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) statistics = session.query(StatisticsShortTerm) recorder_runs = session.query(RecorderRuns) statistics_runs = session.query(StatisticsRuns) @@ -497,6 +516,9 @@ async def test_purge_edge_case( attributes_id=1002, ) ) + instance = recorder.get_instance(hass) + convert_pending_events_to_event_types(instance, session) + convert_pending_states_to_meta(instance, session) await async_setup_recorder_instance(hass, None) await async_wait_purge_done(hass) @@ -512,7 +534,9 @@ async def test_purge_edge_case( state_attributes = session.query(StateAttributes) assert state_attributes.count() == 1 - events = session.query(Events).filter(Events.event_type == "EVENT_TEST_PURGE") + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) assert events.count() == 1 await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, service_data) @@ -524,7 +548,9 @@ async def test_purge_edge_case( with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 0 - events = session.query(Events).filter(Events.event_type == "EVENT_TEST_PURGE") + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) + ) assert events.count() == 0 @@ -594,6 +620,8 @@ async def test_purge_cutoff_date( attributes_id=1000 + row, ) ) + convert_pending_events_to_event_types(instance, session) + convert_pending_states_to_meta(instance, session) instance = await async_setup_recorder_instance(hass, None) await async_wait_purge_done(hass) @@ -608,7 +636,6 @@ async def test_purge_cutoff_date( with session_scope(hass=hass) as session: states = session.query(States) state_attributes = session.query(StateAttributes) - events = session.query(Events) assert states.filter(States.state == "purge").count() == rows - 1 assert states.filter(States.state == "keep").count() == 1 assert ( @@ -619,8 +646,18 @@ async def test_purge_cutoff_date( .count() == 1 ) - assert events.filter(Events.event_type == "PURGE").count() == rows - 1 - assert events.filter(Events.event_type == "KEEP").count() == 1 + assert ( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids(("PURGE",)))) + .count() + == rows - 1 + ) + assert ( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids(("KEEP",)))) + .count() + == 1 + ) instance.queue_task(PurgeTask(cutoff, repack=False, apply_filter=False)) await hass.async_block_till_done() @@ -630,7 +667,7 @@ async def test_purge_cutoff_date( with session_scope(hass=hass) as session: states = session.query(States) state_attributes = session.query(StateAttributes) - events = session.query(Events) + session.query(Events) assert states.filter(States.state == "purge").count() == 0 assert ( state_attributes.outerjoin( @@ -649,8 +686,18 @@ async def test_purge_cutoff_date( .count() == 1 ) - assert events.filter(Events.event_type == "PURGE").count() == 0 - assert events.filter(Events.event_type == "KEEP").count() == 1 + assert ( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids(("PURGE",)))) + .count() + == 0 + ) + assert ( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids(("KEEP",)))) + .count() + == 1 + ) # Make sure we can purge everything instance.queue_task(PurgeTask(dt_util.utcnow(), repack=False, apply_filter=False)) @@ -675,58 +722,6 @@ async def test_purge_cutoff_date( assert state_attributes.count() == 0 -def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> None: - """Convert pending states to use states_metadata.""" - entity_ids: set[str] = set() - states: set[States] = set() - states_meta_objects: dict[str, StatesMeta] = {} - for object in session: - if isinstance(object, States): - entity_ids.add(object.entity_id) - states.add(object) - - entity_id_to_metadata_ids = instance.states_meta_manager.get_many( - entity_ids, session, True - ) - - for state in states: - entity_id = state.entity_id - state.entity_id = None - if metadata_id := entity_id_to_metadata_ids.get(entity_id): - state.metadata_id = metadata_id - continue - if entity_id not in states_meta_objects: - states_meta_objects[entity_id] = StatesMeta(entity_id=entity_id) - state.states_meta_rel = states_meta_objects[entity_id] - - -def _convert_pending_events_to_event_types( - instance: Recorder, session: Session -) -> None: - """Convert pending events to use event_type_ids.""" - event_types: set[str] = set() - events: set[Events] = set() - event_types_objects: dict[str, EventTypes] = {} - for object in session: - if isinstance(object, Events): - event_types.add(object.event_type) - events.add(object) - - event_type_to_event_type_ids = instance.event_type_manager.get_many( - event_types, session - ) - - for event in events: - event_type = event.event_type - event.event_type = None - if event_type_id := event_type_to_event_type_ids.get(event_type): - event.event_type_id = event_type_id - continue - if event_type not in event_types_objects: - event_types_objects[event_type] = EventTypes(event_type=event_type) - event.event_type_rel = event_types_objects[event_type] - - @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -744,7 +739,7 @@ async def test_purge_filtered_states( for days in range(1, 4): timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(1000, 1020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.excluded", "purgeme", @@ -765,7 +760,7 @@ async def test_purge_filtered_states( # Add states and state_changed events that should be keeped timestamp = dt_util.utcnow() - timedelta(days=2) for event_id in range(200, 210): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.keep", "keep", @@ -819,7 +814,8 @@ async def test_purge_filtered_states( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) - _convert_pending_states_to_meta(instance, session) + convert_pending_states_to_meta(instance, session) + convert_pending_events_to_event_types(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -827,12 +823,9 @@ async def test_purge_filtered_states( with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 74 - - events_state_changed = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) ) - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") - assert events_state_changed.count() == 70 assert events_keep.count() == 1 # Normal purge doesn't remove excluded entities @@ -845,11 +838,9 @@ async def test_purge_filtered_states( with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 74 - events_state_changed = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) ) - assert events_state_changed.count() == 70 - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") assert events_keep.count() == 1 # Test with 'apply_filter' = True @@ -866,11 +857,9 @@ async def test_purge_filtered_states( with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 13 - events_state_changed = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) ) - assert events_state_changed.count() == 10 - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") assert events_keep.count() == 1 states_sensor_excluded = ( @@ -945,14 +934,14 @@ async def test_purge_filtered_states_to_empty( for days in range(1, 4): timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(1000, 1020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.excluded", "purgeme", timestamp, event_id * days, ) - _convert_pending_states_to_meta(instance, session) + convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -1028,7 +1017,8 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) - _convert_pending_states_to_meta(instance, session) + convert_pending_states_to_meta(instance, session) + convert_pending_events_to_event_types(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -1065,6 +1055,7 @@ async def test_purge_filtered_events( """Test filtered events are purged.""" config: ConfigType = {"exclude": {"event_types": ["EVENT_PURGE"]}} instance = await async_setup_recorder_instance(hass, config) + await async_wait_recording_done(hass) def _add_db_entries(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1085,29 +1076,26 @@ async def test_purge_filtered_events( # Add states and state_changed events that should be keeped timestamp = dt_util.utcnow() - timedelta(days=1) for event_id in range(200, 210): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.keep", "keep", timestamp, event_id, ) - _convert_pending_events_to_event_types(instance, session) + convert_pending_events_to_event_types(instance, session) + convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} - _add_db_entries(hass) + await instance.async_add_executor_job(_add_db_entries, hass) + await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: events_purge = session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) ) - events_keep = session.query(Events).filter( - Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) - ) states = session.query(States) - assert events_purge.count() == 60 - assert events_keep.count() == 10 assert states.count() == 10 # Normal purge doesn't remove excluded events @@ -1117,16 +1105,12 @@ async def test_purge_filtered_events( await async_recorder_block_till_done(hass) await async_wait_purge_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: events_purge = session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) ) - events_keep = session.query(Events).filter( - Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) - ) states = session.query(States) assert events_purge.count() == 60 - assert events_keep.count() == 10 assert states.count() == 10 # Test with 'apply_filter' = True @@ -1140,16 +1124,12 @@ async def test_purge_filtered_events( await async_recorder_block_till_done(hass) await async_wait_purge_done(hass) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: events_purge = session.query(Events).filter( Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) ) - events_keep = session.query(Events).filter( - Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) - ) states = session.query(States) assert events_purge.count() == 0 - assert events_keep.count() == 10 assert states.count() == 10 @@ -1177,7 +1157,7 @@ async def test_purge_filtered_events_state_changed( for days in range(1, 4): timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(1000, 1020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.excluded", "purgeme", @@ -1242,8 +1222,8 @@ async def test_purge_filtered_events_state_changed( last_updated_ts=dt_util.utc_to_timestamp(timestamp), ) ) - _convert_pending_events_to_event_types(instance, session) - _convert_pending_states_to_meta(instance, session) + convert_pending_events_to_event_types(instance, session) + convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10, "apply_filter": True} _add_db_entries(hass) @@ -1322,7 +1302,7 @@ async def test_purge_entities( for days in range(1, 4): timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(1000, 1020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.purge_entity", "purgeme", @@ -1331,7 +1311,7 @@ async def test_purge_entities( ) timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(10000, 10020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "purge_domain.entity", "purgeme", @@ -1340,28 +1320,30 @@ async def test_purge_entities( ) timestamp = dt_util.utcnow() - timedelta(days=days) for event_id in range(100000, 100020): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "binary_sensor.purge_glob", "purgeme", timestamp, event_id * days, ) - _convert_pending_states_to_meta(instance, session) + convert_pending_states_to_meta(instance, session) + convert_pending_events_to_event_types(instance, session) def _add_keep_records(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: # Add states and state_changed events that should be kept timestamp = dt_util.utcnow() - timedelta(days=2) for event_id in range(200, 210): - _add_state_and_state_changed_event( + _add_state_with_state_attributes( session, "sensor.keep", "keep", timestamp, event_id, ) - _convert_pending_states_to_meta(instance, session) + convert_pending_states_to_meta(instance, session) + convert_pending_events_to_event_types(instance, session) _add_purge_records(hass) _add_keep_records(hass) @@ -1425,7 +1407,7 @@ async def test_purge_entities( await _purge_entities(hass, [], [], []) - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = session.query(States) assert states.count() == 0 @@ -1470,70 +1452,50 @@ 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"} + for _ in range(iterations): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + event_type = "EVENT_TEST_AUTOPURGE" + elif event_id < 4: + timestamp = five_days_ago + event_type = "EVENT_TEST_PURGE" + else: + timestamp = utcnow + event_type = "EVENT_TEST" + with freeze_time(timestamp): + hass.bus.async_fire(event_type, event_data) - await hass.async_block_till_done() await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: - for _ in range(iterations): - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - event_type = "EVENT_TEST_AUTOPURGE" - elif event_id < 4: - timestamp = five_days_ago - event_type = "EVENT_TEST_PURGE" - else: - timestamp = utcnow - event_type = "EVENT_TEST" - - session.add( - Events( - event_type=event_type, - event_data=json.dumps(event_data), - origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), - ) - ) - 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) - event_data = {"test_attr": 5, "test_attr_10": "nice"} 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) - with session_scope(hass=hass) as session: - 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"}' - - event_data = EventData(hash=1234, shared_data=shared_data) - - session.add( - Events( - event_type=event_type, - origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), - event_data_rel=event_data, - ) - ) - async def _add_test_statistics(hass: HomeAssistant): """Add multiple statistics to the db for testing.""" @@ -1639,7 +1601,7 @@ def _add_state_without_event_linkage( ) -def _add_state_and_state_changed_event( +def _add_state_with_state_attributes( session: Session, entity_id: str, state: str, @@ -1662,168 +1624,60 @@ def _add_state_and_state_changed_event( state_attributes=state_attrs, ) ) - session.add( - Events( - event_id=event_id, - event_type=EVENT_STATE_CHANGED, - event_data="{}", - origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), - ) - ) async def test_purge_many_old_events( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test deleting old events.""" - instance = await async_setup_recorder_instance(hass) + old_events_count = 5 + with patch.object(queries, "SQLITE_MAX_BIND_VARS", old_events_count), patch.object( + purge, "SQLITE_MAX_BIND_VARS", old_events_count + ): + instance = await async_setup_recorder_instance(hass) - await _add_test_events(hass, SQLITE_MAX_BIND_VARS) + await _add_test_events(hass, old_events_count) - with session_scope(hass=hass) as session: - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) - assert events.count() == SQLITE_MAX_BIND_VARS * 6 - - purge_before = dt_util.utcnow() - timedelta(days=4) - - # run purge_old_data() - finished = purge_old_data( - instance, - purge_before, - repack=False, - states_batch_size=3, - events_batch_size=3, - ) - assert not finished - assert events.count() == SQLITE_MAX_BIND_VARS * 3 - - # we should only have 2 groups of events left - finished = purge_old_data( - instance, - purge_before, - repack=False, - states_batch_size=3, - events_batch_size=3, - ) - assert finished - assert events.count() == SQLITE_MAX_BIND_VARS * 2 - - # we should now purge everything - finished = purge_old_data( - instance, - dt_util.utcnow(), - repack=False, - states_batch_size=20, - events_batch_size=20, - ) - assert finished - assert events.count() == 0 - - -async def test_purge_can_mix_legacy_and_new_format( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant -) -> None: - """Test purging with legacy a new events.""" - instance = await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - # New databases are no longer created with the legacy events index - assert instance.use_legacy_events_index is False - - def _recreate_legacy_events_index(): - """Recreate the legacy events index since its no longer created on new instances.""" - migration._create_index(instance.get_session, "states", "ix_states_event_id") - instance.use_legacy_events_index = True - - await instance.async_add_executor_job(_recreate_legacy_events_index) - assert instance.use_legacy_events_index is True - - utcnow = dt_util.utcnow() - eleven_days_ago = utcnow - timedelta(days=11) - with session_scope(hass=hass) as session: - broken_state_no_time = States( - event_id=None, - entity_id="orphened.state", - last_updated_ts=None, - last_changed_ts=None, - ) - session.add(broken_state_no_time) - start_id = 50000 - for event_id in range(start_id, start_id + 50): - _add_state_and_state_changed_event( - session, - "sensor.excluded", - "purgeme", - eleven_days_ago, - event_id, + with session_scope(hass=hass) as session: + events = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(TEST_EVENT_TYPES)) ) - await _add_test_events(hass, 50) - await _add_events_with_event_data(hass, 50) - with session_scope(hass=hass) as session: - for _ in range(50): - _add_state_without_event_linkage( - session, "switch.random", "on", eleven_days_ago + assert events.count() == old_events_count * 6 + + purge_before = dt_util.utcnow() - timedelta(days=4) + + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, ) - states_with_event_id = session.query(States).filter( - States.event_id.is_not(None) - ) - states_without_event_id = session.query(States).filter( - States.event_id.is_(None) - ) + assert not finished + assert events.count() == old_events_count * 3 - assert states_with_event_id.count() == 50 - assert states_without_event_id.count() == 51 + # we should only have 2 groups of events left + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, + ) + assert finished + assert events.count() == old_events_count * 2 - purge_before = dt_util.utcnow() - timedelta(days=4) - finished = purge_old_data( - instance, - purge_before, - repack=False, - ) - assert not finished - assert states_with_event_id.count() == 0 - assert states_without_event_id.count() == 51 - # At this point all the legacy states are gone - # and we switch methods - purge_before = dt_util.utcnow() - timedelta(days=4) - finished = purge_old_data( - instance, - purge_before, - repack=False, - events_batch_size=1, - states_batch_size=1, - ) - # Since we only allow one iteration, we won't - # check if we are finished this loop similar - # to the legacy method - assert not finished - assert states_with_event_id.count() == 0 - assert states_without_event_id.count() == 1 - finished = purge_old_data( - instance, - purge_before, - repack=False, - events_batch_size=100, - states_batch_size=100, - ) - assert finished - assert states_with_event_id.count() == 0 - assert states_without_event_id.count() == 1 - _add_state_without_event_linkage( - session, "switch.random", "on", eleven_days_ago - ) - assert states_with_event_id.count() == 0 - assert states_without_event_id.count() == 2 - finished = purge_old_data( - instance, - purge_before, - repack=False, - ) - assert finished - # The broken state without a timestamp - # does not prevent future purges. Its ignored. - assert states_with_event_id.count() == 0 - assert states_without_event_id.count() == 1 + # we should now purge everything + finished = purge_old_data( + instance, + dt_util.utcnow(), + repack=False, + states_batch_size=20, + events_batch_size=20, + ) + assert finished + assert events.count() == 0 async def test_purge_old_events_purges_the_event_type_ids( @@ -1837,7 +1691,6 @@ async def test_purge_old_events_purges_the_event_type_ids( five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) far_past = utcnow - timedelta(days=1000) - event_data = {"test_attr": 5, "test_attr_10": "nice"} await hass.async_block_till_done() await async_wait_recording_done(hass) @@ -1873,8 +1726,6 @@ async def test_purge_old_events_purges_the_event_type_ids( Events( event_type=None, event_type_id=event_type.event_type_id, - event_data=json_dumps(event_data), - origin="LOCAL", time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) @@ -2065,7 +1916,11 @@ async def test_purge_entities_keep_days( await async_recorder_block_till_done(hass) states = await instance.async_add_executor_job( - get_significant_states, hass, one_month_ago + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], ) assert len(states["sensor.keep"]) == 2 assert len(states["sensor.purge"]) == 3 @@ -2082,7 +1937,11 @@ async def test_purge_entities_keep_days( await async_wait_purge_done(hass) states = await instance.async_add_executor_job( - get_significant_states, hass, one_month_ago + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], ) assert len(states["sensor.keep"]) == 2 assert len(states["sensor.purge"]) == 1 @@ -2098,7 +1957,11 @@ async def test_purge_entities_keep_days( await async_wait_purge_done(hass) states = await instance.async_add_executor_job( - get_significant_states, hass, one_month_ago + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], ) assert len(states["sensor.keep"]) == 2 assert "sensor.purge" not in states diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py new file mode 100644 index 00000000000..613c17b3d39 --- /dev/null +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -0,0 +1,1331 @@ +"""Test data purging.""" + +# pylint: disable=invalid-name +from datetime import datetime, timedelta +import json +import sqlite3 +from unittest.mock import MagicMock, patch + +from freezegun import freeze_time +import pytest +from sqlalchemy import text, update +from sqlalchemy.exc import DatabaseError, OperationalError +from sqlalchemy.orm.session import Session + +from homeassistant.components import recorder +from homeassistant.components.recorder import migration +from homeassistant.components.recorder.const import ( + SQLITE_MAX_BIND_VARS, + SupportedDialect, +) +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.components.recorder.purge import purge_old_data +from homeassistant.components.recorder.services import ( + SERVICE_PURGE, + SERVICE_PURGE_ENTITIES, +) +from homeassistant.components.recorder.tasks import PurgeTask +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .common import ( + async_recorder_block_till_done, + async_wait_purge_done, + async_wait_recording_done, + old_db_schema, +) + +from tests.components.recorder.db_schema_32 import ( + EventData, + Events, + RecorderRuns, + StateAttributes, + States, + StatisticsRuns, + StatisticsShortTerm, +) +from tests.typing import RecorderInstanceGenerator + + +@pytest.fixture(autouse=True) +def db_schema_32(): + """Fixture to initialize the db with the old schema 32.""" + with old_db_schema("32"): + yield + + +@pytest.fixture(name="use_sqlite") +def mock_use_sqlite(request): + """Pytest fixture to switch purge method.""" + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", + return_value=SupportedDialect.SQLITE + if request.param + else SupportedDialect.MYSQL, + ): + yield + + +async def _async_attach_db_engine(hass: HomeAssistant) -> None: + """Attach a database engine to the recorder.""" + instance = recorder.get_instance(hass) + + def _mock_setup_recorder_connection(): + with instance.engine.connect() as connection: + instance._setup_recorder_connection( + connection._dbapi_connection, MagicMock() + ) + + await instance.async_add_executor_job(_mock_setup_recorder_connection) + + +async def test_purge_old_states( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old states.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_states(hass) + + # make sure we start with 6 states + with session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + + assert states.count() == 6 + assert states[0].old_state_id is None + assert states[5].old_state_id == states[4].state_id + assert state_attributes.count() == 3 + + events = session.query(Events).filter(Events.event_type == "state_changed") + assert events.count() == 0 + assert "test.recorder2" in instance.states_manager._last_committed_id + + purge_before = dt_util.utcnow() - timedelta(days=4) + + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + states_batch_size=1, + events_batch_size=1, + repack=False, + ) + assert not finished + assert states.count() == 2 + assert state_attributes.count() == 1 + + assert "test.recorder2" in instance.states_manager._last_committed_id + + states_after_purge = list(session.query(States)) + # Since these states are deleted in batches, we can't guarantee the order + # but we can look them up by state + state_map_by_state = {state.state: state for state in states_after_purge} + dontpurgeme_5 = state_map_by_state["dontpurgeme_5"] + dontpurgeme_4 = state_map_by_state["dontpurgeme_4"] + + assert dontpurgeme_5.old_state_id == dontpurgeme_4.state_id + assert dontpurgeme_4.old_state_id is None + + finished = purge_old_data(instance, purge_before, repack=False) + assert finished + assert states.count() == 2 + assert state_attributes.count() == 1 + + assert "test.recorder2" in instance.states_manager._last_committed_id + + # run purge_old_data again + purge_before = dt_util.utcnow() + finished = purge_old_data( + instance, + purge_before, + states_batch_size=1, + events_batch_size=1, + repack=False, + ) + assert not finished + assert states.count() == 0 + assert state_attributes.count() == 0 + + assert "test.recorder2" not in instance.states_manager._last_committed_id + + # Add some more states + await _add_test_states(hass) + + # make sure we start with 6 states + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 6 + assert states[0].old_state_id is None + assert states[5].old_state_id == states[4].state_id + + events = session.query(Events).filter(Events.event_type == "state_changed") + assert events.count() == 0 + assert "test.recorder2" in instance.states_manager._last_committed_id + + state_attributes = session.query(StateAttributes) + assert state_attributes.count() == 3 + + +async def test_purge_old_states_encouters_database_corruption( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, +) -> None: + """Test database image image is malformed while deleting old states.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + # This test is specific for SQLite, wiping the database on error only happens + # with SQLite. + return + + await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_states(hass) + await async_wait_recording_done(hass) + + sqlite3_exception = DatabaseError("statement", {}, []) + sqlite3_exception.__cause__ = sqlite3.DatabaseError() + + with patch( + "homeassistant.components.recorder.core.move_away_broken_database" + ) as move_away, patch( + "homeassistant.components.recorder.purge.purge_old_data", + side_effect=sqlite3_exception, + ): + await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + assert move_away.called + + # Ensure the whole database was reset due to the database error + with session_scope(hass=hass) as session: + states_after_purge = session.query(States) + assert states_after_purge.count() == 0 + + +async def test_purge_old_states_encounters_temporary_mysql_error( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test retry on specific mysql operational errors.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_states(hass) + await async_wait_recording_done(hass) + + mysql_exception = OperationalError("statement", {}, []) + mysql_exception.orig = Exception(1205, "retryable") + + with patch( + "homeassistant.components.recorder.util.time.sleep" + ) as sleep_mock, patch( + "homeassistant.components.recorder.purge._purge_old_recorder_runs", + side_effect=[mysql_exception, None], + ), patch.object( + instance.engine.dialect, "name", "mysql" + ): + await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + assert "retrying" in caplog.text + assert sleep_mock.called + + +async def test_purge_old_states_encounters_operational_error( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error on operational errors that are not mysql does not retry.""" + await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_states(hass) + await async_wait_recording_done(hass) + + exception = OperationalError("statement", {}, []) + + with patch( + "homeassistant.components.recorder.purge._purge_old_recorder_runs", + side_effect=exception, + ): + await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, {"keep_days": 0}) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + assert "retrying" not in caplog.text + assert "Error executing purge" in caplog.text + + +async def test_purge_old_events( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old events.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_events(hass) + + with session_scope(hass=hass) as session: + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + assert events.count() == 6 + + purge_before = dt_util.utcnow() - timedelta(days=4) + + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + assert not finished + assert events.count() == 2 + + # we should only have 2 events left + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + assert finished + assert events.count() == 2 + + +async def test_purge_old_recorder_runs( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old recorder runs keeps current run.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_recorder_runs(hass) + + # make sure we start with 7 recorder runs + with session_scope(hass=hass) as session: + recorder_runs = session.query(RecorderRuns) + assert recorder_runs.count() == 7 + + purge_before = dt_util.utcnow() + + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + assert not finished + + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + assert finished + assert recorder_runs.count() == 1 + + +async def test_purge_old_statistics_runs( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old statistics runs keeps the latest run.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_statistics_runs(hass) + + # make sure we start with 7 statistics runs + with session_scope(hass=hass) as session: + statistics_runs = session.query(StatisticsRuns) + assert statistics_runs.count() == 7 + + purge_before = dt_util.utcnow() + + # run purge_old_data() + finished = purge_old_data(instance, purge_before, repack=False) + assert not finished + + finished = purge_old_data(instance, purge_before, repack=False) + assert finished + assert statistics_runs.count() == 1 + + +@pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) +async def test_purge_method( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + use_sqlite: bool, +) -> None: + """Test purge method.""" + + def assert_recorder_runs_equal(run1, run2): + assert run1.run_id == run2.run_id + assert run1.start == run2.start + assert run1.end == run2.end + assert run1.closed_incorrect == run2.closed_incorrect + assert run1.created == run2.created + + def assert_statistic_runs_equal(run1, run2): + assert run1.run_id == run2.run_id + assert run1.start == run2.start + + await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + service_data = {"keep_days": 4} + await _add_test_events(hass) + await _add_test_states(hass) + await _add_test_statistics(hass) + await _add_test_recorder_runs(hass) + await _add_test_statistics_runs(hass) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # make sure we start with 6 states + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 6 + + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + assert events.count() == 6 + + statistics = session.query(StatisticsShortTerm) + assert statistics.count() == 6 + + recorder_runs = session.query(RecorderRuns) + assert recorder_runs.count() == 7 + runs_before_purge = recorder_runs.all() + + statistics_runs = session.query(StatisticsRuns).order_by(StatisticsRuns.run_id) + assert statistics_runs.count() == 7 + statistic_runs_before_purge = statistics_runs.all() + + for itm in runs_before_purge: + session.expunge(itm) + for itm in statistic_runs_before_purge: + session.expunge(itm) + + await hass.async_block_till_done() + await async_wait_purge_done(hass) + + # run purge method - no service data, use defaults + await hass.services.async_call("recorder", "purge") + await hass.async_block_till_done() + + # Small wait for recorder thread + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + statistics = session.query(StatisticsShortTerm) + + # only purged old states, events and statistics + assert states.count() == 4 + assert events.count() == 4 + assert statistics.count() == 4 + + # run purge method - correct service data + await hass.services.async_call("recorder", "purge", service_data=service_data) + await hass.async_block_till_done() + + # Small wait for recorder thread + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + statistics = session.query(StatisticsShortTerm) + recorder_runs = session.query(RecorderRuns) + statistics_runs = session.query(StatisticsRuns) + + # we should only have 2 states, events and statistics left after purging + assert states.count() == 2 + assert events.count() == 2 + assert statistics.count() == 2 + + # now we should only have 3 recorder runs left + runs = recorder_runs.all() + assert_recorder_runs_equal(runs[0], runs_before_purge[0]) + assert_recorder_runs_equal(runs[1], runs_before_purge[5]) + assert_recorder_runs_equal(runs[2], runs_before_purge[6]) + + # now we should only have 3 statistics runs left + runs = statistics_runs.all() + assert_statistic_runs_equal(runs[0], statistic_runs_before_purge[0]) + assert_statistic_runs_equal(runs[1], statistic_runs_before_purge[5]) + assert_statistic_runs_equal(runs[2], statistic_runs_before_purge[6]) + + assert "EVENT_TEST_PURGE" not in (event.event_type for event in events.all()) + + # run purge method - correct service data, with repack + service_data["repack"] = True + await hass.services.async_call("recorder", "purge", service_data=service_data) + await hass.async_block_till_done() + await async_wait_purge_done(hass) + assert ( + "Vacuuming SQL DB to free space" in caplog.text + or "Optimizing SQL DB to free space" in caplog.text + ) + + +@pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) +async def test_purge_edge_case( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + use_sqlite: bool, +) -> None: + """Test states and events are purged even if they occurred shortly before purge_before.""" + + async def _add_db_entries(hass: HomeAssistant, timestamp: datetime) -> None: + with session_scope(hass=hass) as session: + session.add( + Events( + event_id=1001, + event_type="EVENT_TEST_PURGE", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + session.add( + States( + entity_id="test.recorder2", + state="purgeme", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + event_id=1001, + attributes_id=1002, + ) + ) + session.add( + StateAttributes( + shared_attrs="{}", + hash=1234, + attributes_id=1002, + ) + ) + + await async_setup_recorder_instance(hass, None) + await _async_attach_db_engine(hass) + + await async_wait_purge_done(hass) + + service_data = {"keep_days": 2} + timestamp = dt_util.utcnow() - timedelta(days=2, minutes=1) + + await _add_db_entries(hass, timestamp) + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 1 + + state_attributes = session.query(StateAttributes) + assert state_attributes.count() == 1 + + events = session.query(Events).filter(Events.event_type == "EVENT_TEST_PURGE") + assert events.count() == 1 + + await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, service_data) + await hass.async_block_till_done() + + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 0 + events = session.query(Events).filter(Events.event_type == "EVENT_TEST_PURGE") + assert events.count() == 0 + + +async def test_purge_cutoff_date( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test states and events are purged only if they occurred before "now() - keep_days".""" + + async def _add_db_entries(hass: HomeAssistant, cutoff: datetime, rows: int) -> None: + timestamp_keep = cutoff + timestamp_purge = cutoff - timedelta(microseconds=1) + + with session_scope(hass=hass) as session: + session.add( + Events( + event_id=1000, + event_type="KEEP", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp_keep), + ) + ) + session.add( + States( + entity_id="test.cutoff", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp_keep), + last_updated_ts=dt_util.utc_to_timestamp(timestamp_keep), + event_id=1000, + attributes_id=1000, + ) + ) + session.add( + StateAttributes( + shared_attrs="{}", + hash=1234, + attributes_id=1000, + ) + ) + for row in range(1, rows): + session.add( + Events( + event_id=1000 + row, + event_type="PURGE", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp_purge), + ) + ) + session.add( + States( + entity_id="test.cutoff", + state="purge", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp_purge), + last_updated_ts=dt_util.utc_to_timestamp(timestamp_purge), + event_id=1000 + row, + attributes_id=1000 + row, + ) + ) + session.add( + StateAttributes( + shared_attrs="{}", + hash=1234, + attributes_id=1000 + row, + ) + ) + + instance = await async_setup_recorder_instance(hass, None) + await _async_attach_db_engine(hass) + + await async_wait_purge_done(hass) + + service_data = {"keep_days": 2} + + # Force multiple purge batches to be run + rows = SQLITE_MAX_BIND_VARS + 1 + cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) + await _add_db_entries(hass, cutoff, rows) + + with session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + events = session.query(Events) + assert states.filter(States.state == "purge").count() == rows - 1 + assert states.filter(States.state == "keep").count() == 1 + assert ( + state_attributes.outerjoin( + States, StateAttributes.attributes_id == States.attributes_id + ) + .filter(States.state == "keep") + .count() + == 1 + ) + assert events.filter(Events.event_type == "PURGE").count() == rows - 1 + assert events.filter(Events.event_type == "KEEP").count() == 1 + + instance.queue_task(PurgeTask(cutoff, repack=False, apply_filter=False)) + await hass.async_block_till_done() + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + events = session.query(Events) + assert states.filter(States.state == "purge").count() == 0 + assert ( + state_attributes.outerjoin( + States, StateAttributes.attributes_id == States.attributes_id + ) + .filter(States.state == "purge") + .count() + == 0 + ) + assert states.filter(States.state == "keep").count() == 1 + assert ( + state_attributes.outerjoin( + States, StateAttributes.attributes_id == States.attributes_id + ) + .filter(States.state == "keep") + .count() + == 1 + ) + assert events.filter(Events.event_type == "PURGE").count() == 0 + assert events.filter(Events.event_type == "KEEP").count() == 1 + + # Make sure we can purge everything + instance.queue_task(PurgeTask(dt_util.utcnow(), repack=False, apply_filter=False)) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + assert states.count() == 0 + assert state_attributes.count() == 0 + + # Make sure we can purge everything when the db is already empty + instance.queue_task(PurgeTask(dt_util.utcnow(), repack=False, apply_filter=False)) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + assert states.count() == 0 + assert state_attributes.count() == 0 + + +async def _add_test_states(hass: HomeAssistant): + """Add multiple states to the db for testing.""" + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + base_attributes = {"test_attr": 5, "test_attr_10": "nice"} + + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = f"autopurgeme_{event_id}" + attributes = {"autopurgeme": True, **base_attributes} + elif event_id < 4: + timestamp = five_days_ago + state = f"purgeme_{event_id}" + attributes = {"purgeme": True, **base_attributes} + else: + timestamp = utcnow + state = f"dontpurgeme_{event_id}" + attributes = {"dontpurgeme": True, **base_attributes} + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=timestamp, + ): + await set_state("test.recorder2", state, attributes=attributes) + + +async def _add_test_events(hass: HomeAssistant, iterations: int = 1): + """Add a few events for testing.""" + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + event_data = {"test_attr": 5, "test_attr_10": "nice"} + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + for _ in range(iterations): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + event_type = "EVENT_TEST_AUTOPURGE" + elif event_id < 4: + timestamp = five_days_ago + event_type = "EVENT_TEST_PURGE" + else: + timestamp = utcnow + event_type = "EVENT_TEST" + + session.add( + Events( + event_type=event_type, + event_data=json.dumps(event_data), + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + + +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) + event_data = {"test_attr": 5, "test_attr_10": "nice"} + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + 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"}' + + event_data = EventData(hash=1234, shared_data=shared_data) + + session.add( + Events( + event_type=event_type, + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + event_data_rel=event_data, + ) + ) + + +async def _add_test_statistics(hass: HomeAssistant): + """Add multiple statistics to the db 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() + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = "-11" + elif event_id < 4: + timestamp = five_days_ago + state = "-5" + else: + timestamp = utcnow + state = "0" + + session.add( + StatisticsShortTerm( + start_ts=timestamp.timestamp(), + state=state, + ) + ) + + +async def _add_test_recorder_runs(hass: HomeAssistant): + """Add a few recorder_runs 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() + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + for rec_id in range(6): + if rec_id < 2: + timestamp = eleven_days_ago + elif rec_id < 4: + timestamp = five_days_ago + else: + timestamp = utcnow + + session.add( + RecorderRuns( + start=timestamp, + created=dt_util.utcnow(), + end=timestamp + timedelta(days=1), + ) + ) + + +async def _add_test_statistics_runs(hass: HomeAssistant): + """Add a few recorder_runs 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() + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + for rec_id in range(6): + if rec_id < 2: + timestamp = eleven_days_ago + elif rec_id < 4: + timestamp = five_days_ago + else: + timestamp = utcnow + + session.add( + StatisticsRuns( + start=timestamp, + ) + ) + + +def _add_state_without_event_linkage( + session: Session, + entity_id: str, + state: str, + timestamp: datetime, +): + state_attrs = StateAttributes( + hash=1234, shared_attrs=json.dumps({entity_id: entity_id}) + ) + session.add(state_attrs) + session.add( + States( + entity_id=entity_id, + state=state, + attributes=None, + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + event_id=None, + state_attributes=state_attrs, + ) + ) + + +def _add_state_and_state_changed_event( + session: Session, + entity_id: str, + state: str, + timestamp: datetime, + event_id: int, +) -> None: + """Add state and state_changed event to database for testing.""" + state_attrs = StateAttributes( + hash=event_id, shared_attrs=json.dumps({entity_id: entity_id}) + ) + session.add(state_attrs) + session.add( + States( + entity_id=entity_id, + state=state, + attributes=None, + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + event_id=event_id, + state_attributes=state_attrs, + ) + ) + session.add( + Events( + event_id=event_id, + event_type=EVENT_STATE_CHANGED, + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + + +async def test_purge_many_old_events( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old events.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await _add_test_events(hass, SQLITE_MAX_BIND_VARS) + + with session_scope(hass=hass) as session: + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + assert events.count() == SQLITE_MAX_BIND_VARS * 6 + + purge_before = dt_util.utcnow() - timedelta(days=4) + + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, + ) + assert not finished + assert events.count() == SQLITE_MAX_BIND_VARS * 3 + + # we should only have 2 groups of events left + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, + ) + assert finished + assert events.count() == SQLITE_MAX_BIND_VARS * 2 + + # we should now purge everything + finished = purge_old_data( + instance, + dt_util.utcnow(), + repack=False, + states_batch_size=20, + events_batch_size=20, + ) + assert finished + assert events.count() == 0 + + +async def test_purge_can_mix_legacy_and_new_format( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test purging with legacy and new events.""" + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await async_wait_recording_done(hass) + # New databases are no longer created with the legacy events index + assert instance.use_legacy_events_index is False + + def _recreate_legacy_events_index(): + """Recreate the legacy events index since its no longer created on new instances.""" + migration._create_index(instance.get_session, "states", "ix_states_event_id") + instance.use_legacy_events_index = True + + await instance.async_add_executor_job(_recreate_legacy_events_index) + assert instance.use_legacy_events_index is True + + utcnow = dt_util.utcnow() + eleven_days_ago = utcnow - timedelta(days=11) + + with session_scope(hass=hass) as session: + broken_state_no_time = States( + event_id=None, + entity_id="orphened.state", + last_updated_ts=None, + last_changed_ts=None, + ) + session.add(broken_state_no_time) + start_id = 50000 + for event_id in range(start_id, start_id + 50): + _add_state_and_state_changed_event( + session, + "sensor.excluded", + "purgeme", + eleven_days_ago, + event_id, + ) + await _add_test_events(hass, 50) + await _add_events_with_event_data(hass, 50) + with session_scope(hass=hass) as session: + for _ in range(50): + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + states_with_event_id = session.query(States).filter( + States.event_id.is_not(None) + ) + states_without_event_id = session.query(States).filter( + States.event_id.is_(None) + ) + + assert states_with_event_id.count() == 50 + assert states_without_event_id.count() == 51 + + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 51 + # At this point all the legacy states are gone + # and we switch methods + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + # Since we only allow one iteration, we won't + # check if we are finished this loop similar + # to the legacy method + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=100, + states_batch_size=100, + ) + assert finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 2 + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert finished + # The broken state without a timestamp + # does not prevent future purges. Its ignored. + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + + +async def test_purge_can_mix_legacy_and_new_format_with_detached_state( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, +) -> None: + """Test purging with legacy and new events with a detached state.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + return pytest.skip("This tests disables foreign key checks on SQLite") + + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await async_wait_recording_done(hass) + # New databases are no longer created with the legacy events index + assert instance.use_legacy_events_index is False + + def _recreate_legacy_events_index(): + """Recreate the legacy events index since its no longer created on new instances.""" + migration._create_index(instance.get_session, "states", "ix_states_event_id") + instance.use_legacy_events_index = True + + await instance.async_add_executor_job(_recreate_legacy_events_index) + assert instance.use_legacy_events_index is True + + with session_scope(hass=hass) as session: + session.execute(text("PRAGMA foreign_keys = OFF")) + + utcnow = dt_util.utcnow() + eleven_days_ago = utcnow - timedelta(days=11) + + with session_scope(hass=hass) as session: + broken_state_no_time = States( + event_id=None, + entity_id="orphened.state", + last_updated_ts=None, + last_changed_ts=None, + ) + session.add(broken_state_no_time) + detached_state_deleted_event_id = States( + event_id=99999999999, + entity_id="event.deleted", + last_updated_ts=1, + last_changed_ts=None, + ) + session.add(detached_state_deleted_event_id) + detached_state_deleted_event_id.last_changed = None + detached_state_deleted_event_id.last_changed_ts = None + detached_state_deleted_event_id.last_updated = None + detached_state_deleted_event_id = States( + event_id=99999999999, + entity_id="event.deleted.no_time", + last_updated_ts=None, + last_changed_ts=None, + ) + detached_state_deleted_event_id.last_changed = None + detached_state_deleted_event_id.last_changed_ts = None + detached_state_deleted_event_id.last_updated = None + detached_state_deleted_event_id.last_updated_ts = None + session.add(detached_state_deleted_event_id) + start_id = 50000 + for event_id in range(start_id, start_id + 50): + _add_state_and_state_changed_event( + session, + "sensor.excluded", + "purgeme", + eleven_days_ago, + event_id, + ) + with session_scope(hass=hass) as session: + session.execute( + update(States) + .where(States.entity_id == "event.deleted.no_time") + .values(last_updated_ts=None) + ) + + await _add_test_events(hass, 50) + await _add_events_with_event_data(hass, 50) + with session_scope(hass=hass) as session: + for _ in range(50): + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + states_with_event_id = session.query(States).filter( + States.event_id.is_not(None) + ) + states_without_event_id = session.query(States).filter( + States.event_id.is_(None) + ) + + assert states_with_event_id.count() == 52 + assert states_without_event_id.count() == 51 + + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 51 + # At this point all the legacy states are gone + # and we switch methods + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + # Since we only allow one iteration, we won't + # check if we are finished this loop similar + # to the legacy method + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=100, + states_batch_size=100, + ) + assert finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 2 + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert finished + # The broken state without a timestamp + # does not prevent future purges. Its ignored. + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + + +async def test_purge_entities_keep_days( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test purging states with an entity filter and keep_days.""" + instance = await async_setup_recorder_instance(hass, {}) + await _async_attach_db_engine(hass) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + start = dt_util.utcnow() + two_days_ago = start - timedelta(days=2) + one_week_ago = start - timedelta(days=7) + one_month_ago = start - timedelta(days=30) + with freeze_time(one_week_ago): + hass.states.async_set("sensor.keep", "initial") + hass.states.async_set("sensor.purge", "initial") + + await async_wait_recording_done(hass) + + with freeze_time(two_days_ago): + hass.states.async_set("sensor.purge", "two_days_ago") + + await async_wait_recording_done(hass) + + hass.states.async_set("sensor.purge", "now") + hass.states.async_set("sensor.keep", "now") + await async_recorder_block_till_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 3 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + "keep_days": 1, + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 1 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, + hass, + one_month_ago, + None, + ["sensor.keep", "sensor.purge"], + ) + assert len(states["sensor.keep"]) == 2 + assert "sensor.purge" not in states diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ff429794315..59178f52c8b 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -68,7 +68,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) instance = recorder.get_instance(hass) setup_component(hass, "sensor", {}) zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) # Should not fail if there is nothing there yet @@ -329,7 +329,7 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: hass.block_till_done() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): @@ -418,7 +418,7 @@ def test_rename_entity_collision( hass.block_till_done() zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): @@ -485,7 +485,7 @@ def test_statistics_duplicated( hass = hass_recorder() setup_component(hass, "sensor", {}) zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) wait_recording_done(hass) @@ -1223,6 +1223,59 @@ def test_monthly_statistics( ] } + 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": [ + { + "start": sep_start.timestamp(), + "end": sep_end.timestamp(), + "sum": pytest.approx(3.0), + }, + { + "start": oct_start.timestamp(), + "end": oct_end.timestamp(), + "sum": pytest.approx(5.0), + }, + ] + } + + stats = statistics_during_period( + hass, + start_time=zero, + 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": [ + { + "start": sep_start.timestamp(), + "end": sep_end.timestamp(), + "sum": pytest.approx(3000.0), + }, + { + "start": oct_start.timestamp(), + "end": oct_end.timestamp(), + "sum": pytest.approx(5000.0), + }, + ] + } + # Use 5minute to ensure table switch works stats = statistics_during_period( hass, @@ -1244,28 +1297,21 @@ def test_monthly_statistics( def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, - ) stmt3 = _generate_statistics_during_period_stmt( - columns2, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, + {"sum", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 @@ -1321,18 +1367,13 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) - stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, + stmt3 = _generate_statistics_at_time_stmt( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) - stmt3 = _generate_statistics_at_time_stmt(columns2, StatisticsShortTerm, {0}, 0.0) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 48db847869d..c3d65e7290f 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -3,61 +3,44 @@ The v23 schema used for these tests has been slightly modified to add the EventData table to allow the recorder to startup successfully. """ +from functools import partial + # pylint: disable=invalid-name import importlib import json +from pathlib import Path import sys from unittest.mock import patch -import py import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import Session from homeassistant.components import recorder -from homeassistant.components.recorder import SQLITE_URL_PREFIX, statistics +from homeassistant.components.recorder import SQLITE_URL_PREFIX from homeassistant.components.recorder.util import session_scope from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util -from .common import wait_recording_done +from .common import ( + CREATE_ENGINE_TARGET, + create_engine_test_for_schema_version_postfix, + get_schema_module_path, + wait_recording_done, +) from tests.common import get_test_home_assistant ORIG_TZ = dt_util.DEFAULT_TIME_ZONE -CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_23_with_newer_columns" +SCHEMA_VERSION_POSTFIX = "23_with_newer_columns" +SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) -def _create_engine_test(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION - ) - ) - session.commit() - return engine - - -def test_delete_duplicates( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: +def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) @@ -182,7 +165,13 @@ def test_delete_duplicates( # Create some duplicated statistics with schema version 23 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): + ), patch( + CREATE_ENGINE_TARGET, + new=partial( + create_engine_test_for_schema_version_postfix, + schema_version_postfix=SCHEMA_VERSION_POSTFIX, + ), + ): hass = get_test_home_assistant() recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -226,10 +215,12 @@ def test_delete_duplicates( def test_delete_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) @@ -354,7 +345,13 @@ def test_delete_duplicates_many( # Create some duplicated statistics with schema version 23 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): + ), patch( + CREATE_ENGINE_TARGET, + new=partial( + create_engine_test_for_schema_version_postfix, + schema_version_postfix=SCHEMA_VERSION_POSTFIX, + ), + ): hass = get_test_home_assistant() recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -405,10 +402,12 @@ def test_delete_duplicates_many( @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") def test_delete_duplicates_non_identical( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) @@ -503,7 +502,13 @@ def test_delete_duplicates_non_identical( # Create some duplicated statistics with schema version 23 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): + ), patch( + CREATE_ENGINE_TARGET, + new=partial( + create_engine_test_for_schema_version_postfix, + schema_version_postfix=SCHEMA_VERSION_POSTFIX, + ), + ): hass = get_test_home_assistant() recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -528,7 +533,7 @@ def test_delete_duplicates_non_identical( # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() - hass.config.config_dir = tmpdir + hass.config.config_dir = tmp_path recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) hass.start() @@ -578,10 +583,12 @@ def test_delete_duplicates_non_identical( def test_delete_duplicates_short_term( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) @@ -607,7 +614,13 @@ def test_delete_duplicates_short_term( # Create some duplicated statistics with schema version 23 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): + ), patch( + CREATE_ENGINE_TARGET, + new=partial( + create_engine_test_for_schema_version_postfix, + schema_version_postfix=SCHEMA_VERSION_POSTFIX, + ), + ): hass = get_test_home_assistant() recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -631,7 +644,7 @@ def test_delete_duplicates_short_term( # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() - hass.config.config_dir = tmpdir + hass.config.config_dir = tmp_path recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) hass.start() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 4cc4f4b94a8..ecfd188db8e 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,9 +6,8 @@ from pathlib import Path import sqlite3 from unittest.mock import MagicMock, Mock, patch -import py import pytest -from sqlalchemy import text +from sqlalchemy import lambda_stmt, text from sqlalchemy.engine.result import ChunkedIteratorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.sql.elements import TextClause @@ -19,7 +18,7 @@ from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.history.modern import ( - _get_single_entity_states_stmt, + _get_single_entity_start_time_stmt, ) from homeassistant.components.recorder.models import ( UnsupportedDialect, @@ -73,11 +72,11 @@ def test_recorder_bad_execute(hass_recorder: Callable[..., HomeAssistant]) -> No def test_validate_or_move_away_sqlite_database( - hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Ensure a malformed sqlite database is moved away.""" - - test_dir = tmpdir.mkdir("test_validate_or_move_away_sqlite_database") + test_dir = tmp_path.joinpath("test_validate_or_move_away_sqlite_database") + test_dir.mkdir() test_db_file = f"{test_dir}/broken.db" dburl = f"{SQLITE_URL_PREFIX}{test_db_file}" @@ -894,22 +893,28 @@ def test_execute_stmt_lambda_element( now = dt_util.utcnow() tomorrow = now + timedelta(days=1) one_week_from_now = now + timedelta(days=7) + all_calls = 0 class MockExecutor: def __init__(self, stmt): assert isinstance(stmt, StatementLambdaElement) - self.calls = 0 def all(self): - self.calls += 1 - if self.calls == 2: + nonlocal all_calls + all_calls += 1 + if all_calls == 2: return ["mock_row"] raise SQLAlchemyError with session_scope(hass=hass) as session: # No time window, we always get a list metadata_id = instance.states_meta_manager.get("sensor.on", session, True) - stmt = _get_single_entity_states_stmt(dt_util.utcnow(), metadata_id, False) + start_time_ts = dt_util.utcnow().timestamp() + stmt = lambda_stmt( + lambda: _get_single_entity_start_time_stmt( + start_time_ts, metadata_id, False, False + ) + ) rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state @@ -922,6 +927,16 @@ def test_execute_stmt_lambda_element( assert row.state == new_state.state assert row.metadata_id == metadata_id + # Time window >= 2 days, we should not get a ChunkedIteratorResult + # because orm_rows=False + rows = util.execute_stmt_lambda_element( + session, stmt, now, one_week_from_now, orm_rows=False + ) + assert not isinstance(rows, ChunkedIteratorResult) + row = next(rows) + assert row.state == new_state.state + assert row.metadata_id == metadata_id + # Time window < 2 days, we get a list rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert isinstance(rows, list) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 6e424558181..dae4fb39c59 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -3,10 +3,10 @@ import asyncio from datetime import timedelta import importlib +from pathlib import Path import sys from unittest.mock import patch -import py import pytest from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session @@ -52,11 +52,11 @@ def _create_engine_test(*args, **kwargs): return engine -async def test_migrate_times( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: +async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None: """Test we can migrate times.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) @@ -225,10 +225,12 @@ async def test_migrate_times( async def test_migrate_can_resume_entity_id_post_migration( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local + caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test we resume the entity id post migration after a restart.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + test_dir = tmp_path.joinpath("sqlite") + test_dir.mkdir() + test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8e760b40100..335bdafd643 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2203,7 +2203,9 @@ async def test_recorder_info_migration_queue_exhausted( "homeassistant.components.recorder.core.create_engine", new=create_engine_test, ), patch.object( - recorder.core, "MAX_QUEUE_BACKLOG", 1 + recorder.core, "MAX_QUEUE_BACKLOG_MIN_VALUE", 1 + ), patch.object( + recorder.core, "QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY", 0 ), patch( "homeassistant.components.recorder.migration._apply_update", wraps=stalled_migration, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index be748ef2c40..d36aea905f7 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,8 +39,6 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, - ), patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: @@ -65,6 +63,13 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None yield host_mock +@pytest.fixture +def reolink_ONVIF_wait() -> Generator[None, None, None]: + """Mock reolink connection.""" + with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): + yield + + @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b3abb793a9f..7d25fd62811 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -28,7 +28,9 @@ from .conftest import ( from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures( + "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" +) async def test_config_flow_manual_success(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 57d0dbd7cb7..8dd6db270fb 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,5 +1,4 @@ """Test the Reolink init.""" -import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -55,6 +54,7 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -71,7 +71,10 @@ async def test_failures_parametrized( async def test_entry_reloading( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -88,7 +91,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -106,7 +109,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -125,6 +128,7 @@ async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -144,10 +148,7 @@ async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - with patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", - AsyncMock(side_effect=asyncio.TimeoutError()), - ): + with patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -156,7 +157,10 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 99d378983b9..86bac75de91 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus +import ssl from unittest.mock import MagicMock, patch import httpx @@ -9,7 +10,11 @@ import pytest import respx from homeassistant import config as hass_config -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.rest import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -18,7 +23,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,27 +34,27 @@ from tests.common import get_fixture_path async def test_setup_missing_basic_config(hass: HomeAssistant) -> None: """Test setup with configuration missing required entries.""" assert await async_setup_component( - hass, Platform.BINARY_SENSOR, {"binary_sensor": {"platform": "rest"}} + hass, BINARY_SENSOR_DOMAIN, {BINARY_SENSOR_DOMAIN: {"platform": DOMAIN}} ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 0 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 async def test_setup_missing_config(hass: HomeAssistant) -> None: """Test setup with configuration missing required entries.""" assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 0 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 @respx.mock @@ -64,37 +68,59 @@ async def test_setup_failed_connect( ) assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 0 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 assert "server offline" in caplog.text +@respx.mock +async def test_setup_fail_on_ssl_erros( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup when connection error occurs.""" + respx.get("https://localhost").mock(side_effect=ssl.SSLError("ssl error")) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "https://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 + assert "ssl error" in caplog.text + + @respx.mock async def test_setup_timeout(hass: HomeAssistant) -> None: """Test setup when connection timeout occurs.""" respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 0 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 @respx.mock @@ -103,17 +129,17 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 @respx.mock @@ -122,16 +148,16 @@ async def test_setup_minimum_resource_template(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource_template": "{% set url = 'http://localhost' %}{{ url }}", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 @respx.mock @@ -140,17 +166,17 @@ async def test_setup_duplicate_resource_template(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "resource_template": "http://localhost", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 0 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 0 @respx.mock @@ -159,10 +185,10 @@ async def test_setup_get(hass: HomeAssistant) -> None: respx.get("http://localhost").respond(status_code=HTTPStatus.OK, json={}) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -179,7 +205,7 @@ async def test_setup_get(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 state = hass.states.get("binary_sensor.foo") assert state.state == STATE_OFF @@ -195,7 +221,7 @@ async def test_setup_get_template_headers_params(hass: HomeAssistant) -> None: "sensor", { "sensor": { - "platform": "rest", + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -227,10 +253,10 @@ async def test_setup_get_digest_auth(hass: HomeAssistant) -> None: respx.get("http://localhost").respond(status_code=HTTPStatus.OK, json={}) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -246,7 +272,7 @@ async def test_setup_get_digest_auth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 @respx.mock @@ -255,10 +281,10 @@ async def test_setup_post(hass: HomeAssistant) -> None: respx.post("http://localhost").respond(status_code=HTTPStatus.OK, json={}) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "POST", "value_template": "{{ value_json.key }}", @@ -274,7 +300,7 @@ async def test_setup_post(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 @respx.mock @@ -287,10 +313,10 @@ async def test_setup_get_off(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.dog }}", @@ -301,7 +327,7 @@ async def test_setup_get_off(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 state = hass.states.get("binary_sensor.foo") assert state.state == STATE_OFF @@ -317,10 +343,10 @@ async def test_setup_get_on(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.dog }}", @@ -331,7 +357,7 @@ async def test_setup_get_on(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 state = hass.states.get("binary_sensor.foo") assert state.state == STATE_ON @@ -343,10 +369,10 @@ async def test_setup_with_exception(hass: HomeAssistant) -> None: respx.get("http://localhost").respond(status_code=HTTPStatus.OK, json={}) assert await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.dog }}", @@ -357,7 +383,7 @@ async def test_setup_with_exception(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 state = hass.states.get("binary_sensor.foo") assert state.state == STATE_OFF @@ -387,10 +413,10 @@ async def test_reload(hass: HomeAssistant) -> None: await async_setup_component( hass, - "binary_sensor", + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "method": "GET", "name": "mockrest", "resource": "http://localhost", @@ -401,14 +427,14 @@ async def test_reload(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 assert hass.states.get("binary_sensor.mockrest") - yaml_path = get_fixture_path("configuration.yaml", "rest") + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( - "rest", + DOMAIN, SERVICE_RELOAD, {}, blocking=True, @@ -425,10 +451,10 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: respx.get("http://localhost", params={"search": "something"}) % HTTPStatus.OK assert await async_setup_component( hass, - Platform.BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, { - "binary_sensor": { - "platform": "rest", + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "params": {"search": "something"}, @@ -436,7 +462,7 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("binary_sensor")) == 1 + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 @respx.mock @@ -444,9 +470,9 @@ async def test_entity_config(hass: HomeAssistant) -> None: """Test entity configuration.""" config = { - Platform.BINARY_SENSOR: { + BINARY_SENSOR_DOMAIN: { # REST configuration - "platform": "rest", + "platform": DOMAIN, "method": "GET", "resource": "http://localhost", # Entity configuration @@ -458,7 +484,7 @@ async def test_entity_config(hass: HomeAssistant) -> None: } respx.get("http://localhost") % HTTPStatus.OK - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() entity_registry = er.async_get(hass) diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py index 79d59be3f44..e19c7dc3cc7 100644 --- a/tests/components/rest/test_init.py +++ b/tests/components/rest/test_init.py @@ -3,8 +3,10 @@ import asyncio from datetime import timedelta from http import HTTPStatus +import ssl from unittest.mock import patch +import pytest import respx from homeassistant import config as hass_config @@ -133,6 +135,46 @@ async def test_setup_with_endpoint_timeout_with_recovery(hass: HomeAssistant) -> assert hass.states.get("binary_sensor.binary_sensor2").state == "off" +@respx.mock +async def test_setup_with_ssl_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup with an ssl error.""" + await async_setup_component(hass, "homeassistant", {}) + + respx.get("https://localhost").mock(side_effect=ssl.SSLError("ssl error")) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "https://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": UnitOfInformation.MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + assert "ssl error" in caplog.text + + @respx.mock async def test_setup_minimum_resource_template(hass: HomeAssistant) -> None: """Test setup with minimum configuration (resource_template).""" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 5ae8530c295..fd595ef07a6 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,7 +1,8 @@ """The tests for the REST sensor platform.""" import asyncio from http import HTTPStatus -from unittest.mock import MagicMock, patch +import ssl +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -9,9 +10,10 @@ import respx from homeassistant import config as hass_config from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.rest import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -28,26 +30,29 @@ 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.ssl import SSLCipherList from tests.common import get_fixture_path async def test_setup_missing_config(hass: HomeAssistant) -> None: """Test setup with configuration missing required entries.""" - assert await async_setup_component(hass, DOMAIN, {"sensor": {"platform": "rest"}}) + assert await async_setup_component( + hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": DOMAIN}} + ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 0 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 async def test_setup_missing_schema(hass: HomeAssistant) -> None: """Test setup with resource missing schema.""" assert await async_setup_component( hass, - DOMAIN, - {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, + SENSOR_DOMAIN, + {SENSOR_DOMAIN: {"platform": DOMAIN, "resource": "localhost", "method": "GET"}}, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 0 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 @respx.mock @@ -60,31 +65,53 @@ async def test_setup_failed_connect( ) assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 0 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 assert "server offline" in caplog.text +@respx.mock +async def test_setup_fail_on_ssl_erros( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup when connection error occurs.""" + respx.get("https://localhost").mock(side_effect=ssl.SSLError("ssl error")) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "https://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 + assert "ssl error" in caplog.text + + @respx.mock async def test_setup_timeout(hass: HomeAssistant) -> None: """Test setup when connection timeout occurs.""" respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) assert await async_setup_component( hass, - DOMAIN, - {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, + SENSOR_DOMAIN, + {SENSOR_DOMAIN: {"platform": DOMAIN, "resource": "localhost", "method": "GET"}}, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 0 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 @respx.mock @@ -93,17 +120,17 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 @respx.mock @@ -115,22 +142,60 @@ async def test_setup_encoding(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { + SENSOR_DOMAIN: { "name": "mysensor", "encoding": "iso-8859-1", - "platform": "rest", + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.mysensor").state == "tack själv" +@respx.mock +@pytest.mark.parametrize( + ("ssl_cipher_list", "ssl_cipher_list_expected"), + ( + ("python_default", SSLCipherList.PYTHON_DEFAULT), + ("intermediate", SSLCipherList.INTERMEDIATE), + ("modern", SSLCipherList.MODERN), + ), +) +async def test_setup_ssl_ciphers( + hass: HomeAssistant, ssl_cipher_list: str, ssl_cipher_list_expected: SSLCipherList +) -> None: + """Test setup with minimum configuration.""" + with patch( + "homeassistant.components.rest.data.create_async_httpx_client", + return_value=MagicMock(request=AsyncMock(return_value=respx.MockResponse())), + ) as httpx: + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "ssl_cipher_list": ssl_cipher_list, + } + }, + ) + await hass.async_block_till_done() + httpx.assert_called_once_with( + hass, + verify_ssl=True, + default_encoding="UTF-8", + ssl_cipher_list=ssl_cipher_list_expected, + ) + + @respx.mock async def test_manual_update(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" @@ -140,19 +205,19 @@ async def test_manual_update(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { + SENSOR_DOMAIN: { "name": "mysensor", "value_template": "{{ value_json.data }}", - "platform": "rest", + "platform": DOMAIN, "resource_template": "{% set url = 'http://localhost' %}{{ url }}", "method": "GET", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.mysensor").state == "first" respx.get("http://localhost").respond( @@ -173,16 +238,16 @@ async def test_setup_minimum_resource_template(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource_template": "{% set url = 'http://localhost' %}{{ url }}", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 @respx.mock @@ -191,17 +256,17 @@ async def test_setup_duplicate_resource_template(hass: HomeAssistant) -> None: respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "resource_template": "http://localhost", } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 0 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 0 @respx.mock @@ -212,10 +277,10 @@ async def test_setup_get(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -235,7 +300,7 @@ async def test_setup_get(hass: HomeAssistant) -> None: await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.foo").state == "123" await hass.services.async_call( @@ -262,10 +327,10 @@ async def test_setup_timestamp( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -276,7 +341,7 @@ async def test_setup_timestamp( await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.rest_sensor") assert state.state == "2021-11-11T11:39:00+00:00" @@ -321,10 +386,10 @@ async def test_setup_get_templated_headers_params(hass: HomeAssistant) -> None: respx.get("http://localhost").respond(status_code=200, json={}) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -358,10 +423,10 @@ async def test_setup_get_digest_auth(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -378,7 +443,7 @@ async def test_setup_get_digest_auth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 @respx.mock @@ -389,10 +454,10 @@ async def test_setup_post(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "POST", "value_template": "{{ value_json.key }}", @@ -409,7 +474,7 @@ async def test_setup_post(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 @respx.mock @@ -422,10 +487,10 @@ async def test_setup_get_xml(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.dog }}", @@ -437,7 +502,7 @@ async def test_setup_get_xml(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "123" @@ -450,10 +515,10 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: respx.get("http://localhost", params={"search": "something"}) % HTTPStatus.OK assert await async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "params": {"search": "something"}, @@ -461,7 +526,7 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 @respx.mock @@ -474,10 +539,10 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -490,7 +555,7 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "123" @@ -507,10 +572,10 @@ async def test_update_with_no_template(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "json_attributes": ["key"], @@ -522,7 +587,7 @@ async def test_update_with_no_template(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == '{"key": "some_json_value"}' @@ -541,10 +606,10 @@ async def test_update_with_json_attrs_no_data( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -558,7 +623,7 @@ async def test_update_with_json_attrs_no_data( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN @@ -578,10 +643,10 @@ async def test_update_with_json_attrs_not_dict( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -594,7 +659,7 @@ async def test_update_with_json_attrs_not_dict( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "" @@ -615,10 +680,10 @@ async def test_update_with_json_attrs_bad_JSON( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", @@ -632,7 +697,7 @@ async def test_update_with_json_attrs_bad_JSON( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN @@ -658,10 +723,10 @@ async def test_update_with_json_attrs_with_json_attrs_path(hass: HomeAssistant) ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.toplevel.master_value }}", @@ -676,7 +741,7 @@ async def test_update_with_json_attrs_with_json_attrs_path(hass: HomeAssistant) }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "123" @@ -697,10 +762,10 @@ async def test_update_with_xml_convert_json_attrs_with_json_attrs_path( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.toplevel.master_value }}", @@ -714,7 +779,7 @@ async def test_update_with_xml_convert_json_attrs_with_json_attrs_path( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "123" @@ -735,10 +800,10 @@ async def test_update_with_xml_convert_json_attrs_with_jsonattr_template( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.response.bss.wlan }}", @@ -752,7 +817,7 @@ async def test_update_with_xml_convert_json_attrs_with_jsonattr_template( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "123" @@ -776,10 +841,10 @@ async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_temp ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.main.dog }}", @@ -793,7 +858,7 @@ async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_temp }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == "1" @@ -814,10 +879,10 @@ async def test_update_with_xml_convert_bad_xml( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.toplevel.master_value }}", @@ -830,7 +895,7 @@ async def test_update_with_xml_convert_bad_xml( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN @@ -851,10 +916,10 @@ async def test_update_with_failed_get( ) assert await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.toplevel.master_value }}", @@ -867,7 +932,7 @@ async def test_update_with_failed_get( }, ) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") assert state.state == STATE_UNKNOWN @@ -883,10 +948,10 @@ async def test_reload(hass: HomeAssistant) -> None: await async_setup_component( hass, - "sensor", + SENSOR_DOMAIN, { - "sensor": { - "platform": "rest", + SENSOR_DOMAIN: { + "platform": DOMAIN, "method": "GET", "name": "mockrest", "resource": "http://localhost", @@ -897,14 +962,14 @@ async def test_reload(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 1 + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.mockrest") - yaml_path = get_fixture_path("configuration.yaml", "rest") + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( - "rest", + DOMAIN, SERVICE_RELOAD, {}, blocking=True, @@ -920,9 +985,9 @@ async def test_entity_config(hass: HomeAssistant) -> None: """Test entity configuration.""" config = { - DOMAIN: { + SENSOR_DOMAIN: { # REST configuration - "platform": "rest", + "platform": DOMAIN, "method": "GET", "resource": "http://localhost", # Entity configuration @@ -937,7 +1002,7 @@ async def test_entity_config(hass: HomeAssistant) -> None: } respx.get("http://localhost").respond(status_code=HTTPStatus.OK, text="123") - assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() entity_registry = er.async_get(hass) diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index ae7d507e857..655f172833b 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -437,7 +437,7 @@ async def test_entity_config( config = { SWITCH_DOMAIN: { # REST configuration - CONF_PLATFORM: "rest", + CONF_PLATFORM: DOMAIN, CONF_METHOD: "POST", CONF_RESOURCE: "http://localhost", # Entity configuration diff --git a/tests/components/roborock/__init__.py b/tests/components/roborock/__init__.py new file mode 100644 index 00000000000..f5de63d5819 --- /dev/null +++ b/tests/components/roborock/__init__.py @@ -0,0 +1 @@ +"""Tests for Roborock integration.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py new file mode 100644 index 00000000000..d767505feeb --- /dev/null +++ b/tests/components/roborock/conftest.py @@ -0,0 +1,59 @@ +"""Global fixtures for Roborock integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.roborock.const import ( + CONF_BASE_URL, + CONF_USER_DATA, + DOMAIN, +) +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 tests.common import MockConfigEntry + + +@pytest.fixture(name="bypass_api_fixture") +def bypass_api_fixture() -> None: + """Skip calls to the API.""" + with patch("homeassistant.components.roborock.RoborockMqttClient.connect"), patch( + "homeassistant.components.roborock.RoborockMqttClient.send_command" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=PROP, + ): + yield + + +@pytest.fixture +def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a Roborock Entry that has not been setup.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + title=USER_EMAIL, + data={ + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: BASE_URL, + }, + ) + mock_entry.add_to_hass(hass) + return mock_entry + + +@pytest.fixture +async def setup_entry( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Roborock platform.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + return_value=HOME_DATA, + ), patch("homeassistant.components.roborock.RoborockMqttClient.get_networking"): + 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 new file mode 100644 index 00000000000..cbd5ef379e8 --- /dev/null +++ b/tests/components/roborock/mock_data.py @@ -0,0 +1,370 @@ +"""Mock data for Roborock tests.""" +from __future__ import annotations + +from roborock.containers import ( + CleanRecord, + CleanSummary, + Consumable, + DNDTimer, + HomeData, + Status, + UserData, +) +from roborock.typing import DeviceProp + +# All data is based on a U.S. customer with a Roborock S7 MaxV Ultra +USER_EMAIL = "user@domain.com" + +BASE_URL = "https://usiot.roborock.com" + +USER_DATA = UserData.from_dict( + { + "tuyaname": "abc123", + "tuyapwd": "abc123", + "uid": 123456, + "tokentype": "", + "token": "abc123", + "rruid": "abc123", + "region": "us", + "countrycode": "1", + "country": "US", + "nickname": "user_nickname", + "rriot": { + "u": "abc123", + "s": "abc123", + "h": "abc123", + "k": "abc123", + "r": { + "r": "US", + "a": "https://api-us.roborock.com", + "m": "ssl://mqtt-us-2.roborock.com:8883", + "l": "https://wood-us.roborock.com", + }, + }, + "tuyaDeviceState": 2, + "avatarurl": "https://files.roborock.com/iottest/default_avatar.png", + } +) + +MOCK_CONFIG = { + "username": USER_EMAIL, + "user_data": USER_DATA.as_dict(), + "base_url": None, +} + +HOME_DATA_RAW = { + "id": 123456, + "name": "My Home", + "lon": None, + "lat": None, + "geoName": None, + "products": [ + { + "id": "abc123", + "name": "Roborock S7 MaxV", + "code": "a27", + "model": "roborock.vacuum.a27", + "iconUrl": None, + "attribute": None, + "capability": 0, + "category": "robot.vacuum.cleaner", + "schema": [ + { + "id": "101", + "name": "rpc_request", + "code": "rpc_request", + "mode": "rw", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "102", + "name": "rpc_response", + "code": "rpc_response", + "mode": "rw", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "120", + "name": "错误代码", + "code": "error_code", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "121", + "name": "设备状态", + "code": "state", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "122", + "name": "设备电量", + "code": "battery", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "123", + "name": "清扫模式", + "code": "fan_power", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "124", + "name": "拖地模式", + "code": "water_box_mode", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "125", + "name": "主刷寿命", + "code": "main_brush_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "126", + "name": "边刷寿命", + "code": "side_brush_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "127", + "name": "滤网寿命", + "code": "filter_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "128", + "name": "额外状态", + "code": "additional_props", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "130", + "name": "完成事件", + "code": "task_complete", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "131", + "name": "电量不足任务取消", + "code": "task_cancel_low_power", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "132", + "name": "运动中任务取消", + "code": "task_cancel_in_motion", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "133", + "name": "充电状态", + "code": "charge_status", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "134", + "name": "烘干状态", + "code": "drying_status", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + ], + } + ], + "devices": [ + { + "duid": "abc123", + "name": "Roborock S7 MaxV", + "attribute": None, + "activeTime": 1672364449, + "localKey": "abc123", + "runtimeEnv": None, + "timeZoneId": "America/Los_Angeles", + "iconUrl": "", + "productId": "abc123", + "lon": None, + "lat": None, + "share": False, + "shareTime": None, + "online": True, + "fv": "02.56.02", + "pv": "1.0", + "roomId": 2362003, + "tuyaUuid": None, + "tuyaMigrated": False, + "extra": '{"RRPhotoPrivacyVersion": "1"}', + "sn": "abc123", + "featureSet": "2234201184108543", + "newFeatureSet": "0000000000002041", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 102, + "124": 203, + "125": 94, + "126": 90, + "127": 87, + "128": 0, + "133": 1, + "120": 0, + }, + "silentOtaSwitch": True, + } + ], + "receivedDevices": [], + "rooms": [ + {"id": 2362048, "name": "Example room 1"}, + {"id": 2362044, "name": "Example room 2"}, + {"id": 2362041, "name": "Example room 3"}, + ], +} + +HOME_DATA: HomeData = HomeData.from_dict(HOME_DATA_RAW) + +CLEAN_RECORD = CleanRecord.from_dict( + { + "begin": 1672543330, + "end": 1672544638, + "duration": 1176, + "area": 20965000, + "error": 0, + "complete": 1, + "start_type": 2, + "clean_type": 3, + "finish_reason": 56, + "dust_collection_status": 1, + "avoid_count": 19, + "wash_count": 2, + "map_flag": 0, + } +) + +CLEAN_SUMMARY = CleanSummary.from_dict( + { + "clean_time": 74382, + "clean_area": 1159182500, + "clean_count": 31, + "dust_collection_count": 25, + "records": [ + 1672543330, + 1672458041, + ], + } +) + +CONSUMABLE = Consumable.from_dict( + { + "main_brush_work_time": 74382, + "side_brush_work_time": 74382, + "filter_work_time": 74382, + "filter_element_work_time": 0, + "sensor_dirty_time": 74382, + "strainer_work_times": 65, + "dust_collection_work_times": 25, + "cleaning_brush_work_times": 65, + } +) + +DND_TIMER = DNDTimer.from_dict( + { + "start_hour": 22, + "start_minute": 0, + "end_hour": 7, + "end_minute": 0, + "enabled": 1, + } +) + +STATUS = Status.from_dict( + { + "msg_ver": 2, + "msg_seq": 458, + "state": 8, + "battery": 100, + "clean_time": 1176, + "clean_area": 20965000, + "error_code": 0, + "map_present": 1, + "in_cleaning": 0, + "in_returning": 0, + "in_fresh_state": 1, + "lab_status": 1, + "water_box_status": 1, + "back_type": -1, + "wash_phase": 0, + "wash_ready": 0, + "fan_power": 102, + "dnd_enabled": 0, + "map_status": 3, + "is_locating": 0, + "lock_status": 0, + "water_box_mode": 203, + "water_box_carriage_status": 1, + "mop_forbidden_enable": 1, + "camera_status": 3457, + "is_exploring": 0, + "home_sec_status": 0, + "home_sec_enable_password": 0, + "adbumper_status": [0, 0, 0], + "water_shortage_status": 0, + "dock_type": 3, + "dust_collection_status": 0, + "auto_dust_collection": 1, + "avoid_count": 19, + "mop_mode": 300, + "debug_mode": 0, + "collision_avoid_status": 1, + "switch_map_mode": 0, + "dock_error_status": 0, + "charge_status": 1, + "unsave_map_reason": 0, + "unsave_map_flag": 0, + } +) + +PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py new file mode 100644 index 00000000000..2f297135d15 --- /dev/null +++ b/tests/components/roborock/test_config_flow.py @@ -0,0 +1,179 @@ +"""Test Roborock config flow.""" +from unittest.mock import patch + +import pytest +from roborock.exceptions import ( + RoborockAccountDoesNotExist, + RoborockException, + RoborockInvalidCode, + RoborockInvalidEmail, + RoborockUrlException, +) + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN +from homeassistant.core import HomeAssistant + +from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL + + +async def test_config_flow_success( + hass: HomeAssistant, + bypass_api_fixture, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": USER_EMAIL} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + ( + "request_code_side_effect", + "request_code_errors", + ), + [ + (RoborockException(), {"base": "unknown_roborock"}), + (RoborockAccountDoesNotExist(), {"base": "invalid_email"}), + (RoborockInvalidEmail(), {"base": "invalid_email_format"}), + (RoborockUrlException(), {"base": "unknown_url"}), + (Exception(), {"base": "unknown"}), + ], +) +async def test_config_flow_failures_request_code( + hass: HomeAssistant, + bypass_api_fixture, + request_code_side_effect: Exception | None, + request_code_errors: dict[str, str], +) -> None: + """Handle applying errors to request code recovering from the errors.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code", + side_effect=request_code_side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": USER_EMAIL} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == request_code_errors + # Recover from error + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": USER_EMAIL} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + ( + "code_login_side_effect", + "code_login_errors", + ), + [ + (RoborockException(), {"base": "unknown_roborock"}), + (RoborockInvalidCode(), {"base": "invalid_code"}), + (Exception(), {"base": "unknown"}), + ], +) +async def test_config_flow_failures_code_login( + hass: HomeAssistant, + bypass_api_fixture, + code_login_side_effect: Exception | None, + code_login_errors: dict[str, str], +) -> None: + """Handle applying errors to code login and recovering from the errors.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": USER_EMAIL} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + # Raise exception for invalid code + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + side_effect=code_login_side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == code_login_errors + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py new file mode 100644 index 00000000000..05bf0848475 --- /dev/null +++ b/tests/components/roborock/test_init.py @@ -0,0 +1,40 @@ +"""Test for Roborock init.""" +from unittest.mock import patch + +from homeassistant.components.roborock.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry +) -> None: + """Test unloading roboorck integration.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert setup_entry.state is ConfigEntryState.LOADED + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.async_disconnect" + ) as mock_disconnect: + assert await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert mock_disconnect.call_count == 1 + assert setup_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> None: + """Test that when coordinator update fails, entry retries.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + ), patch( + "homeassistant.components.roborock.RoborockDataUpdateCoordinator._async_update_data", + side_effect=UpdateFailed(), + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py new file mode 100644 index 00000000000..3b0ba8183b3 --- /dev/null +++ b/tests/components/roborock/test_select.py @@ -0,0 +1,58 @@ +"""Test Roborock Select platform.""" +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.const import SERVICE_SELECT_OPTION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.roborock_s7_maxv_mop_mode", "deep"), + ("select.roborock_s7_maxv_mop_intensity", "mild"), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + value: str, +) -> None: + """Test allowed changing values for select entities.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": value}, + 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( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "deep"}, + blocking=True, + target={"entity_id": "select.roborock_s7_maxv_mop_mode"}, + ) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py new file mode 100644 index 00000000000..f6cc5e81d1b --- /dev/null +++ b/tests/components/roborock/test_vacuum.py @@ -0,0 +1,91 @@ +"""Tests for Roborock vacuums.""" + + +from typing import Any +from unittest.mock import patch + +import pytest +from roborock.typing import RoborockCommand + +from homeassistant.components.vacuum import ( + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SEND_COMMAND, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_START_PAUSE, + SERVICE_STOP, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.roborock_s7_maxv" +DEVICE_ID = "abc123" + + +async def test_registry_entries( + hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry +) -> None: + """Tests devices are registered in the entity registry.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(ENTITY_ID) + assert entry.unique_id == DEVICE_ID + + +@pytest.mark.parametrize( + ("service", "command", "service_params", "called_params"), + [ + (SERVICE_START, RoborockCommand.APP_START, None, None), + (SERVICE_PAUSE, RoborockCommand.APP_PAUSE, None, None), + (SERVICE_STOP, RoborockCommand.APP_STOP, None, None), + (SERVICE_RETURN_TO_BASE, RoborockCommand.APP_CHARGE, None, None), + (SERVICE_CLEAN_SPOT, RoborockCommand.APP_SPOT, None, None), + (SERVICE_LOCATE, RoborockCommand.FIND_ME, None, None), + (SERVICE_START_PAUSE, RoborockCommand.APP_START, None, None), + ( + SERVICE_SET_FAN_SPEED, + RoborockCommand.SET_CUSTOM_MODE, + {"fan_speed": "silent"}, + [101], + ), + ( + SERVICE_SEND_COMMAND, + RoborockCommand.GET_LED_STATUS, + {"command": "get_led_status"}, + None, + ), + ], +) +async def test_commands( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + service: str, + command: str, + service_params: dict[str, Any], + called_params: list | None, +) -> None: + """Test sending commands to the vacuum.""" + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, **(service_params or {})} + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command" + ) as mock_send_command: + await hass.services.async_call( + Platform.VACUUM, + service, + data, + 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 diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py index 0cfd9ba5d5c..13885f06d3e 100644 --- a/tests/components/rtsp_to_webrtc/test_config_flow.py +++ b/tests/components/rtsp_to_webrtc/test_config_flow.py @@ -123,6 +123,7 @@ async def test_hassio_discovery(hass: HomeAssistant) -> None: }, name="RTSPtoWebRTC", slug="rtsp-to-webrtc", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -162,6 +163,7 @@ async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: }, name="RTSPtoWebRTC", slug="rtsp-to-webrtc", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -184,6 +186,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: }, name="RTSPtoWebRTC", slug="rtsp-to-webrtc", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -203,6 +206,7 @@ async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: }, name="RTSPtoWebRTC", slug="rtsp-to-webrtc", + uuid="1234", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index a6d2d34b178..27656dd10c7 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup @@ -27,6 +28,12 @@ OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_setup_success( hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup ) -> None: diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index ee1660653d9..58a171f9102 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -54,7 +54,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index 7204fce3f44..4e98ea9e670 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -66,7 +66,9 @@ async def test_exclude_attributes( await async_wait_recording_done(hass) assert len(calls) == 1 - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/season/test_config_flow.py b/tests/components/season/test_config_flow.py index 6579bb53a9b..884c5a3ddc8 100644 --- a/tests/components/season/test_config_flow.py +++ b/tests/components/season/test_config_flow.py @@ -20,7 +20,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 075a6e2486a..903d24d39bb 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -19,6 +19,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test select registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} ) @@ -27,7 +28,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 8be15f1c7cd..82ea25b5a11 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1803,20 +1803,20 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - ("native_value", "expected"), + "native_value", [ - ("abc", "abc"), - ("13.7.1", "13.7.1"), - (datetime(2012, 11, 10, 7, 35, 1), "2012-11-10 07:35:01"), - (date(2012, 11, 10), "2012-11-10"), + "", + "abc", + "13.7.1", + datetime(2012, 11, 10, 7, 35, 1), + date(2012, 11, 10), ], ) -async def test_non_numeric_validation_warn( +async def test_non_numeric_validation_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, - expected: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1837,7 +1837,7 @@ async def test_non_numeric_validation_warn( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == expected + assert state is None assert ( "thus indicating it has a numeric value; " diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index f3e373f5a63..9b297bf884f 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -32,14 +32,13 @@ from homeassistant.components.recorder.statistics import ( list_statistic_ids, ) from homeassistant.components.recorder.util import get_instance, session_scope -from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN +from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from tests.common import async_fire_time_changed from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, assert_multiple_states_equal_without_context_and_last_changed, @@ -157,7 +156,9 @@ def test_compile_hourly_statistics( "unit_of_measurement": state_unit, } four, states = record_states(hass, zero, "sensor.test1", attributes) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -275,7 +276,9 @@ def test_compile_hourly_statistics_with_some_same_last_updated( set_state(entity_id, str(seq[3]), attributes=attributes) ) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -384,7 +387,9 @@ def test_compile_hourly_statistics_with_all_same_last_updated( set_state(entity_id, str(seq[3]), attributes=attributes) ) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -491,7 +496,9 @@ def test_compile_hourly_statistics_only_state_is_and_end_of_period( set_state(entity_id, str(seq[3]), attributes=attributes) ) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -553,7 +560,9 @@ def test_compile_hourly_statistics_purged_state_changes( "unit_of_measurement": state_unit, } four, states = record_states(hass, zero, "sensor.test1", attributes) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) mean = min = max = float(hist["sensor.test1"][-1].state) @@ -565,7 +574,9 @@ def test_compile_hourly_statistics_purged_state_changes( hass.services.call("recorder", "purge", {"keep_days": 0}) hass.block_till_done() wait_recording_done(hass) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert not hist do_adhoc_statistics(hass, start=zero) @@ -638,7 +649,9 @@ def test_compile_hourly_statistics_wrong_unit( _, _states = record_states(hass, zero, "sensor.test7", attributes_tmp) states = {**states, **_states} - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -837,7 +850,10 @@ async def test_compile_hourly_sum_statistics_amount( ) await async_wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -1039,6 +1055,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( hass, zero - timedelta.resolution, two + timedelta.resolution, + hass.states.async_entity_ids(), significant_changes_only=False, ) assert_multiple_states_equal_without_context_and_last_changed( @@ -1146,6 +1163,7 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( hass, zero - timedelta.resolution, one + timedelta.resolution, + hass.states.async_entity_ids(), significant_changes_only=False, ) assert_multiple_states_equal_without_context_and_last_changed( @@ -1239,6 +1257,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( hass, zero - timedelta.resolution, one + timedelta.resolution, + hass.states.async_entity_ids(), significant_changes_only=False, ) assert_multiple_states_equal_without_context_and_last_changed( @@ -1354,6 +1373,7 @@ def test_compile_hourly_sum_statistics_negative_state( mocksensor._attr_should_poll = False platform.ENTITIES["custom_sensor"] = mocksensor + setup_component(hass, "homeassistant", {}) setup_component( hass, "sensor", {"sensor": [{"platform": "demo"}, {"platform": "test"}]} ) @@ -1380,6 +1400,7 @@ def test_compile_hourly_sum_statistics_negative_state( hass, zero - timedelta.resolution, one + timedelta.resolution, + hass.states.async_entity_ids(), significant_changes_only=False, ) assert_multiple_states_equal_without_context_and_last_changed( @@ -1471,7 +1492,10 @@ def test_compile_hourly_sum_statistics_total_no_reset( ) wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -1580,7 +1604,10 @@ def test_compile_hourly_sum_statistics_total_increasing( ) wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -1687,7 +1714,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( ) wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -1796,7 +1826,10 @@ def test_compile_hourly_energy_statistics_unsupported( wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -1890,7 +1923,10 @@ def test_compile_hourly_energy_statistics_multiple( states = {**states, **_states} wait_recording_done(hass) hist = history.get_significant_states( - hass, period0 - timedelta.resolution, eight + timedelta.resolution + hass, + period0 - timedelta.resolution, + eight + timedelta.resolution, + hass.states.async_entity_ids(), ) assert_multiple_states_equal_without_context_and_last_changed( dict(states)["sensor.test1"], dict(hist)["sensor.test1"] @@ -2079,7 +2115,9 @@ def test_compile_hourly_statistics_unchanged( "unit_of_measurement": state_unit, } four, states = record_states(hass, zero, "sensor.test1", attributes) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=four) @@ -2113,7 +2151,9 @@ def test_compile_hourly_statistics_partially_unavailable( four, states = record_states_partially_unavailable( hass, zero, "sensor.test1", TEMPERATURE_SENSOR_ATTRIBUTES ) - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -2186,7 +2226,9 @@ def test_compile_hourly_statistics_unavailable( ) _, _states = record_states(hass, zero, "sensor.test2", attributes) states = {**states, **_states} - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=four) @@ -2408,7 +2450,9 @@ def test_compile_hourly_statistics_changing_units_1( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -2527,7 +2571,9 @@ def test_compile_hourly_statistics_changing_units_2( hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero + timedelta(seconds=30 * 5)) @@ -2604,7 +2650,9 @@ def test_compile_hourly_statistics_changing_units_3( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -2752,7 +2800,9 @@ def test_compile_hourly_statistics_convert_units_1( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) wait_recording_done(hass) @@ -2854,7 +2904,9 @@ def test_compile_hourly_statistics_equivalent_units_1( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) @@ -2968,7 +3020,9 @@ def test_compile_hourly_statistics_equivalent_units_2( hass, zero + timedelta(minutes=5), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero + timedelta(seconds=30 * 5)) @@ -3094,7 +3148,9 @@ def test_compile_hourly_statistics_changing_device_class_1( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) # Run statistics again, additional statistics is generated @@ -3149,7 +3205,9 @@ def test_compile_hourly_statistics_changing_device_class_1( hass, zero + timedelta(minutes=20), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) # Run statistics again, additional statistics is generated @@ -3294,7 +3352,9 @@ def test_compile_hourly_statistics_changing_device_class_2( hass, zero + timedelta(minutes=10), "sensor.test1", attributes ) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, zero, four) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) # Run statistics again, additional statistics is generated @@ -3419,7 +3479,9 @@ 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) states["sensor.test1"] += _states["sensor.test1"] - hist = history.get_significant_states(hass, period0, four) + hist = history.get_significant_states( + hass, period0, four, hass.states.async_entity_ids() + ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=period1) @@ -3606,7 +3668,11 @@ def test_compile_statistics_hourly_daily_monthly_summary( start += timedelta(minutes=5) hist = history.get_significant_states( - hass, zero - timedelta.resolution, four, significant_changes_only=False + hass, + zero - timedelta.resolution, + four, + hass.states.async_entity_ids(), + significant_changes_only=False, ) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) wait_recording_done(hass) @@ -4592,7 +4658,7 @@ async def test_validate_statistics_unit_change_no_conversion( assert response["result"] == expected_result async def assert_statistic_ids(expected_result): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) for i in range(len(db_states)): @@ -4727,7 +4793,7 @@ async def test_validate_statistics_unit_change_equivalent_units( assert response["result"] == expected_result async def assert_statistic_ids(expected_result): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) for i in range(len(db_states)): @@ -4813,7 +4879,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( assert response["result"] == expected_result async def assert_statistic_ids(expected_result): - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) for i in range(len(db_states)): @@ -5059,16 +5125,26 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): return four, states -async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_exclude_attributes( + recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test sensor attributes to be excluded.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) - await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + has_entity_name=True, + unique_id="test", + name="Test", + native_value="option1", + device_class=SensorDeviceClass.ENUM, + options=["option1", "option2"], + ) + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() await async_wait_recording_done(hass) def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: native_states = [] for db_state, db_state_attributes, db_states_meta in ( session.query(States, StateAttributes, StatesMeta) @@ -5085,9 +5161,6 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) return native_states states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - if state.domain != DOMAIN: - continue - assert ATTR_OPTIONS not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + assert len(states) == 1 + assert ATTR_OPTIONS not in states[0].attributes + assert ATTR_FRIENDLY_NAME in states[0].attributes diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 56619e3434e..b308b5ab3af 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -88,31 +88,31 @@ }), ]) # --- -# name: test_binary_sensors[adsl][binary_sensor.sfr_box_dsl_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'SFR Box DSL status', +# name: test_binary_sensors[adsl].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', }), - 'context': , - 'entity_id': 'binary_sensor.sfr_box_dsl_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_binary_sensors[adsl][binary_sensor.sfr_box_wan_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'SFR Box WAN status', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box DSL status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_dsl_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', }), - 'context': , - 'entity_id': 'binary_sensor.sfr_box_wan_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) + ]) # --- # name: test_binary_sensors[ftth] list([ @@ -203,29 +203,29 @@ }), ]) # --- -# name: test_binary_sensors[ftth][binary_sensor.sfr_box_ftth_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'SFR Box FTTH status', +# name: test_binary_sensors[ftth].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', }), - 'context': , - 'entity_id': 'binary_sensor.sfr_box_ftth_status', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[ftth][binary_sensor.sfr_box_wan_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'SFR Box WAN status', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box FTTH status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_ftth_status', + 'last_changed': , + 'last_updated': , + 'state': 'off', }), - 'context': , - 'entity_id': 'binary_sensor.sfr_box_wan_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) + ]) # --- diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index e2aca4f28d0..dc6ccc1f25d 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -60,16 +60,18 @@ }), ]) # --- -# name: test_buttons[button.sfr_box_restart] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'restart', - 'friendly_name': 'SFR Box Restart', +# name: test_buttons.2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'SFR Box Restart', + }), + 'context': , + 'entity_id': 'button.sfr_box_restart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', }), - 'context': , - 'entity_id': 'button.sfr_box_restart', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) + ]) # --- diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 287354cbfef..2390ba625eb 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -1,30 +1,32 @@ # serializer version: 1 # name: test_sensors - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://192.168.0.1', - 'connections': set({ + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'sfr_box', - 'e4:5d:51:00:11:22', - ), - }), - 'is_new': False, - 'manufacturer': None, - 'model': 'NB6VAC-FXC-r0', - 'name': 'SFR Box', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': 'NB6VAC-MAIN-R4.0.44k', - 'via_device_id': None, - }) + ]) # --- # name: test_sensors.1 list([ @@ -499,242 +501,216 @@ }), ]) # --- -# name: test_sensors[sensor.sfr_box_dsl_attenuation_down] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'SFR Box DSL attenuation down', - 'state_class': , - 'unit_of_measurement': 'dB', +# name: test_sensors.2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box Network infrastructure', + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_network_infrastructure', + 'last_changed': , + 'last_updated': , + 'state': 'adsl', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', - 'last_changed': , - 'last_updated': , - 'state': '28.5', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_attenuation_up] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'SFR Box DSL attenuation up', - 'state_class': , - 'unit_of_measurement': 'dB', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SFR Box Voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_voltage', + 'last_changed': , + 'last_updated': , + 'state': '12251', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', - 'last_changed': , - 'last_updated': , - 'state': '20.8', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_counter] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'SFR Box DSL counter', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SFR Box Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.56', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_counter', - 'last_changed': , - 'last_updated': , - 'state': '16', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_crc] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'SFR Box DSL CRC', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box WAN mode', + 'options': list([ + 'adsl_ppp', + 'adsl_routed', + 'ftth_routed', + 'grps_ppp', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_wan_mode', + 'last_changed': , + 'last_updated': , + 'state': 'adsl_routed', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_crc', - 'last_changed': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_line_mode] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'SFR Box DSL line mode', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL line mode', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_mode', + 'last_changed': , + 'last_updated': , + 'state': 'ADSL2+', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_line_mode', - 'last_changed': , - 'last_updated': , - 'state': 'ADSL2+', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_line_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'SFR Box DSL line status', - 'options': list([ - 'no_defect', - 'of_frame', - 'loss_of_signal', - 'loss_of_power', - 'loss_of_signal_quality', - 'unknown', - ]), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL counter', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_counter', + 'last_changed': , + 'last_updated': , + 'state': '16', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_line_status', - 'last_changed': , - 'last_updated': , - 'state': 'no_defect', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_noise_down] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'SFR Box DSL noise down', - 'state_class': , - 'unit_of_measurement': 'dB', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL CRC', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_crc', + 'last_changed': , + 'last_updated': , + 'state': '0', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_noise_down', - 'last_changed': , - 'last_updated': , - 'state': '5.8', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_noise_up] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'SFR Box DSL noise up', - 'state_class': , - 'unit_of_measurement': 'dB', + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_down', + 'last_changed': , + 'last_updated': , + 'state': '5.8', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_noise_up', - 'last_changed': , - 'last_updated': , - 'state': '6.0', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_rate_down] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_rate', - 'friendly_name': 'SFR Box DSL rate down', - 'state_class': , - 'unit_of_measurement': , + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_up', + 'last_changed': , + 'last_updated': , + 'state': '6.0', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_rate_down', - 'last_changed': , - 'last_updated': , - 'state': '5549', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_rate_up] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_rate', - 'friendly_name': 'SFR Box DSL rate up', - 'state_class': , - 'unit_of_measurement': , + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', + 'last_changed': , + 'last_updated': , + 'state': '28.5', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_rate_up', - 'last_changed': , - 'last_updated': , - 'state': '187', - }) -# --- -# name: test_sensors[sensor.sfr_box_dsl_training] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'SFR Box DSL training', - 'options': list([ - 'idle', - 'g_994_training', - 'g_992_started', - 'g_922_channel_analysis', - 'g_992_message_exchange', - 'g_993_started', - 'g_993_channel_analysis', - 'g_993_message_exchange', - 'showtime', - 'unknown', - ]), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', + 'last_changed': , + 'last_updated': , + 'state': '20.8', }), - 'context': , - 'entity_id': 'sensor.sfr_box_dsl_training', - 'last_changed': , - 'last_updated': , - 'state': 'showtime', - }) -# --- -# name: test_sensors[sensor.sfr_box_network_infrastructure] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'SFR Box Network infrastructure', - 'options': list([ - 'adsl', - 'ftth', - 'gprs', - 'unknown', - ]), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate down', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_down', + 'last_changed': , + 'last_updated': , + 'state': '5549', }), - 'context': , - 'entity_id': 'sensor.sfr_box_network_infrastructure', - 'last_changed': , - 'last_updated': , - 'state': 'adsl', - }) -# --- -# name: test_sensors[sensor.sfr_box_temperature] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'SFR Box Temperature', - 'unit_of_measurement': , + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate up', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_up', + 'last_changed': , + 'last_updated': , + 'state': '187', }), - 'context': , - 'entity_id': 'sensor.sfr_box_temperature', - 'last_changed': , - 'last_updated': , - 'state': '27.56', - }) -# --- -# name: test_sensors[sensor.sfr_box_voltage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'SFR Box Voltage', - 'unit_of_measurement': , + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL line status', + 'options': list([ + 'no_defect', + 'of_frame', + 'loss_of_signal', + 'loss_of_power', + 'loss_of_signal_quality', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_status', + 'last_changed': , + 'last_updated': , + 'state': 'no_defect', }), - 'context': , - 'entity_id': 'sensor.sfr_box_voltage', - 'last_changed': , - 'last_updated': , - 'state': '12251', - }) -# --- -# name: test_sensors[sensor.sfr_box_wan_mode] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'SFR Box WAN mode', - 'options': list([ - 'adsl_ppp', - 'adsl_routed', - 'ftth_routed', - 'grps_ppp', - 'unknown', - ]), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL training', + 'options': list([ + 'idle', + 'g_994_training', + 'g_992_started', + 'g_922_channel_analysis', + 'g_992_message_exchange', + 'g_993_started', + 'g_993_channel_analysis', + 'g_993_message_exchange', + 'showtime', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_training', + 'last_changed': , + 'last_updated': , + 'state': 'showtime', }), - 'context': , - 'entity_id': 'sensor.sfr_box_wan_mode', - 'last_changed': , - 'last_updated': , - 'state': 'adsl_routed', - }) + ]) # --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index db6124bec3d..65f3c8f8c0e 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -38,15 +38,18 @@ async def test_binary_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + # Ensure devices are correctly registered device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) assert device_entries == snapshot + # Ensure entities are correctly registered entity_entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) assert entity_entries == snapshot - for entity in entity_entries: - assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index 40a71feb084..5a833056291 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -36,18 +36,21 @@ async def test_buttons( await hass.config_entries.async_setup(config_entry_with_auth.entry_id) await hass.async_block_till_done() + # Ensure devices are correctly registered device_entries = dr.async_entries_for_config_entry( device_registry, config_entry_with_auth.entry_id ) assert device_entries == snapshot + # Ensure entities are correctly registered entity_entries = er.async_entries_for_config_entry( entity_registry, config_entry_with_auth.entry_id ) assert entity_entries == snapshot - for entity in entity_entries: - assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot async def test_reboot(hass: HomeAssistant, config_entry_with_auth: ConfigEntry) -> None: diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index ff84defb832..c374837c5a7 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sfr_box import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -32,19 +31,24 @@ async def test_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({(DOMAIN, "e4:5d:51:00:11:22")}) - assert device_entry == snapshot + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + # Ensure entities are correctly registered entity_entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) assert entity_entries == snapshot - for entity in entity_entries: - entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) - + # 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() - for entity in entity_entries: - assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index bfd781f03c6..e5f1e30efdb 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -5,12 +5,14 @@ import pytest from homeassistant.components.shopping_list import NoMatchingShoppingListItem from homeassistant.components.shopping_list.const import ( + ATTR_REVERSE, DOMAIN, EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, SERVICE_CLEAR_COMPLETED_ITEMS, SERVICE_COMPLETE_ITEM, SERVICE_REMOVE_ITEM, + SERVICE_SORT, ) from homeassistant.components.websocket_api.const import ( ERR_INVALID_FORMAT, @@ -657,8 +659,6 @@ async def test_add_item_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_NAME: "beer"}, blocking=True, ) - await hass.async_block_till_done() - assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 @@ -672,15 +672,12 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_NAME: "beer"}, blocking=True, ) - await hass.async_block_till_done() await hass.services.async_call( DOMAIN, SERVICE_ADD_ITEM, {ATTR_NAME: "cheese"}, blocking=True, ) - await hass.async_block_till_done() - assert len(hass.data[DOMAIN].items) == 2 assert len(events) == 2 @@ -690,8 +687,6 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None: {ATTR_NAME: "beer"}, blocking=True, ) - await hass.async_block_till_done() - assert len(hass.data[DOMAIN].items) == 1 assert hass.data[DOMAIN].items[0]["name"] == "cheese" assert len(events) == 3 @@ -706,7 +701,6 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N {ATTR_NAME: "beer"}, blocking=True, ) - await hass.async_block_till_done() assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 @@ -717,7 +711,6 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N {ATTR_NAME: "beer"}, blocking=True, ) - await hass.async_block_till_done() assert len(hass.data[DOMAIN].items) == 1 assert len(events) == 1 @@ -728,6 +721,44 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N {}, blocking=True, ) - await hass.async_block_till_done() assert len(hass.data[DOMAIN].items) == 0 assert len(events) == 1 + + +async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None: + """Test sort_all service.""" + + for name in ("zzz", "ddd", "aaa"): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: name}, + blocking=True, + ) + + # sort ascending + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + await hass.services.async_call( + DOMAIN, + SERVICE_SORT, + {ATTR_REVERSE: False}, + blocking=True, + ) + + assert hass.data[DOMAIN].items[0][ATTR_NAME] == "aaa" + assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" + assert hass.data[DOMAIN].items[2][ATTR_NAME] == "zzz" + assert len(events) == 1 + + # sort descending + await hass.services.async_call( + DOMAIN, + SERVICE_SORT, + {ATTR_REVERSE: True}, + blocking=True, + ) + + assert hass.data[DOMAIN].items[0][ATTR_NAME] == "zzz" + assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" + assert hass.data[DOMAIN].items[2][ATTR_NAME] == "aaa" + assert len(events) == 2 diff --git a/tests/components/sia/test_config_flow.py b/tests/components/sia/test_config_flow.py index 096ff4cf02f..ef252991d7c 100644 --- a/tests/components/sia/test_config_flow.py +++ b/tests/components/sia/test_config_flow.py @@ -328,6 +328,7 @@ async def test_options_basic(hass: HomeAssistant) -> None: updated = await hass.config_entries.options.async_configure( result["flow_id"], BASIC_OPTIONS ) + await hass.async_block_till_done() assert updated["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert updated["data"] == { CONF_ACCOUNTS: {BASIC_CONFIG[CONF_ACCOUNT]: BASIC_OPTIONS} diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 07fe2aee02c..5961b925a2a 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -46,6 +46,12 @@ MOCK_DETECTIONS = { MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3) +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_detections(): """Return a mock detection.""" diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index 77b08135fab..76b497e024a 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -27,7 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 9932b75ebdb..05104546f0d 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -5,6 +5,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( + BED_PRESETS, Side, SleepIQActuator, SleepIQBed, @@ -118,6 +119,7 @@ def mock_asyncsleepiq_single_foundation( preset.preset = PRESET_R_STATE preset.side = Side.NONE preset.side_full = "Right" + preset.options = BED_PRESETS yield client @@ -157,10 +159,12 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]: preset_l.preset = PRESET_L_STATE preset_l.side = Side.LEFT preset_l.side_full = "Left" + preset_l.options = BED_PRESETS preset_r.preset = PRESET_R_STATE preset_r.side = Side.RIGHT preset_r.side_full = "Right" + preset_r.options = BED_PRESETS yield client diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index a9989787517..f08d1b54985 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -179,7 +179,7 @@ async def test_scenes_unauthorized_loads_platforms( smartthings_mock.subscriptions.return_value = subscriptions with patch.object(hass.config_entries, "async_forward_entry_setup") as forward_mock: - assert await smartthings.async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert platforms loaded await hass.async_block_till_done() assert forward_mock.call_count == len(PLATFORMS) @@ -211,7 +211,7 @@ async def test_config_entry_loads_platforms( smartthings_mock.subscriptions.return_value = subscriptions with patch.object(hass.config_entries, "async_forward_entry_setup") as forward_mock: - assert await smartthings.async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert platforms loaded await hass.async_block_till_done() assert forward_mock.call_count == len(PLATFORMS) @@ -243,7 +243,7 @@ async def test_config_entry_loads_unconnected_cloud( ] smartthings_mock.subscriptions.return_value = subscriptions with patch.object(hass.config_entries, "async_forward_entry_setup") as forward_mock: - assert await smartthings.async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert forward_mock.call_count == len(PLATFORMS) @@ -480,6 +480,8 @@ async def test_event_handler_dispatches_updated_devices( assert devices[3].status.attributes["lock"].value == "locked" assert devices[3].status.attributes["lock"].data == {"codeId": "1"} + broker.disconnect() + async def test_event_handler_ignores_other_installed_app( hass: HomeAssistant, config_entry, device_factory, event_request_factory @@ -502,6 +504,8 @@ async def test_event_handler_ignores_other_installed_app( assert not called + broker.disconnect() + async def test_event_handler_fires_button_events( hass: HomeAssistant, @@ -542,3 +546,5 @@ async def test_event_handler_fires_button_events( await hass.async_block_till_done() assert called + + broker.disconnect() diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py new file mode 100644 index 00000000000..a325bd41bd7 --- /dev/null +++ b/tests/components/snapcast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Snapcast integration.""" diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py new file mode 100644 index 00000000000..00d031192d8 --- /dev/null +++ b/tests/components/snapcast/conftest.py @@ -0,0 +1,23 @@ +"""Test the snapcast config flow.""" +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.snapcast.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_create_server() -> Generator[AsyncMock, None, None]: + """Create mock snapcast connection.""" + mock_connection = AsyncMock() + mock_connection.start = AsyncMock(return_value=None) + with patch("snapcast.control.create_server", return_value=mock_connection): + yield mock_connection diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py new file mode 100644 index 00000000000..b6ff43503a6 --- /dev/null +++ b/tests/components/snapcast/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the Snapcast module.""" + +import socket +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test we get the form and handle errors and successful connection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # test invalid host error + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # test connection error + with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # test success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert len(mock_create_server.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test config flow abort if device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONNECTION, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py index 1e38978f447..1e414fb337c 100644 --- a/tests/components/snooz/__init__.py +++ b/tests/components/snooz/__init__.py @@ -5,7 +5,8 @@ from dataclasses import dataclass from unittest.mock import patch from pysnooz.commands import SnoozCommandData -from pysnooz.testing import MockSnoozDevice +from pysnooz.device import DisconnectionReason +from pysnooz.testing import MockSnoozDevice as ParentMockSnoozDevice from homeassistant.components.snooz.const import DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_TOKEN @@ -65,6 +66,18 @@ class SnoozFixture: device: MockSnoozDevice +class MockSnoozDevice(ParentMockSnoozDevice): + """Used for testing integration with Bleak. + + Adjusted for https://github.com/AustinBrunkhorst/pysnooz/issues/6 + """ + + def _on_device_disconnected(self, e) -> None: + if self._is_manually_disconnecting: + e.kwargs.set("reason", DisconnectionReason.USER) + return super()._on_device_disconnected(e) + + async def create_mock_snooz( connected: bool = True, initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0), diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index ef420c11ef2..4e01ba02edd 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -112,6 +112,7 @@ def soco_fixture( mock_soco.loudness = True mock_soco.volume = 19 mock_soco.audio_delay = 2 + mock_soco.balance = (61, 100) mock_soco.bass = 1 mock_soco.treble = -1 mock_soco.mic_enabled = False diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 6cc79e1b2f0..596946e9f8b 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,6 +1,6 @@ """Tests for the Sonos config flow.""" import logging -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -86,16 +86,16 @@ async def test_async_poll_manual_hosts_warnings( manager, "_async_handle_discovery_message" ), patch("homeassistant.components.sonos.async_call_later"), patch( "homeassistant.components.sonos.async_dispatcher_send" - ), patch.object( - hass, "async_add_executor_job", new=AsyncMock() - ) as mock_async_add_executor_job: - mock_async_add_executor_job.side_effect = [ + ), patch( + "homeassistant.components.sonos.sync_get_visible_zones", + side_effect=[ OSError(), OSError(), [], [], OSError(), - ] + ], + ): # First call fails, it should be logged as a WARNING message caplog.clear() await manager.async_poll_manual_hosts() diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index a393f699a57..d5da2af629e 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -11,6 +11,10 @@ async def test_number_entities( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test number entities.""" + balance_number = entity_registry.entities["number.zone_a_balance"] + balance_state = hass.states.get(balance_number.entity_id) + assert balance_state.state == "39" + bass_number = entity_registry.entities["number.zone_a_bass"] bass_state = hass.states.get(bass_number.entity_id) assert bass_state.state == "1" diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py new file mode 100644 index 00000000000..b99704559ec --- /dev/null +++ b/tests/components/sonos/test_repairs.py @@ -0,0 +1,47 @@ +"""Test repairs handling for Sonos.""" +from unittest.mock import Mock + +from homeassistant.components.sonos.const import ( + DOMAIN, + SCAN_INTERVAL, + SUB_FAIL_ISSUE_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.util import dt as dt_util + +from .conftest import SonosMockEvent + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_subscription_repair_issues( + hass: HomeAssistant, config_entry: MockConfigEntry, soco, zgs_discovery +): + """Test repair issues handling for failed subscriptions.""" + issue_registry = async_get_issue_registry(hass) + + subscription = soco.zoneGroupTopology.subscribe.return_value + subscription.event_listener = Mock(address=("192.168.4.2", 1400)) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Ensure an issue is registered on subscription failure + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + + # Ensure the issue still exists after reload + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + + # Ensure the issue has been removed after a successful subscription callback + variables = {"ZoneGroupState": zgs_discovery} + event = SonosMockEvent(soco, soco.zoneGroupTopology, variables) + sub_callback = subscription.callback + sub_callback(event) + await hass.async_block_till_done() + assert not issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 9b497708a7a..e9b85c22eb3 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -17,6 +17,7 @@ async def test_fallback_to_polling( speaker = list(hass.data[DATA_SONOS].discovered.values())[0] assert speaker.soco is soco assert speaker._subscriptions + assert not speaker.subscriptions_failed caplog.clear() @@ -29,7 +30,6 @@ async def test_fallback_to_polling( assert not speaker._subscriptions assert speaker.subscriptions_failed - assert "falling back to polling" in caplog.text assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 97df7fe253e..5a941b37d63 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -42,6 +42,19 @@ ENTRY_CONFIG_INVALID_QUERY_OPT = { CONF_UNIT_OF_MEASUREMENT: "MiB", } +ENTRY_CONFIG_INVALID_COLUMN_NAME = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + ENTRY_CONFIG_NO_RESULTS = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 3213296a479..a8e590a9760 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( ENTRY_CONFIG, + ENTRY_CONFIG_INVALID_COLUMN_NAME, + ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ENTRY_CONFIG_INVALID_QUERY, ENTRY_CONFIG_INVALID_QUERY_OPT, ENTRY_CONFIG_NO_RESULTS, @@ -120,6 +122,43 @@ async def test_flow_fails_invalid_query( } +async def test_flow_fails_invalid_column_name( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test config flow fails invalid column name.""" + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == "user" + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME, + ) + + assert result5["type"] == FlowResultType.FORM + assert result5["errors"] == { + "column": "column_invalid", + } + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["title"] == "Get Value" + assert result5["options"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "value_template": None, + } + + async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( @@ -318,6 +357,60 @@ async def test_options_flow_fails_invalid_query( } +async def test_options_flow_fails_invalid_column_name( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test options flow fails invalid column name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "value_template": None, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sql.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == { + "column": "column_invalid", + } + + result4 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + }, + ) + + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["data"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + } + + async def test_options_flow_db_url_empty( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -371,3 +464,108 @@ async def test_options_flow_db_url_empty( "column": "size", "unit_of_measurement": "MiB", } + + +async def test_full_flow_not_recorder_db( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test full config flow with not using recorder db.""" + 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, + ), patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "db_url": "sqlite://path/to/db.db", + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Get Value" + assert result2["options"] == { + "name": "Get Value", + "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] + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + with patch( + "homeassistant.components.sql.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": "sqlite://path/to/db.db", + "column": "value", + "unit_of_measurement": "MiB", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + } + + # Need to test same again to mitigate issue with db_url removal + result = await hass.config_entries.options.async_init(entry.entry_id) + with patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": "sqlite://path/to/db.db", + "column": "value", + "unit_of_measurement": "MB", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MB", + } + + assert entry.options == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MB", + } diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index 682a43c4429..99a5da84fe2 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -1,54 +1,81 @@ """Tests for the SRP Energy integration.""" -from unittest.mock import patch -from homeassistant import config_entries -from homeassistant.components import srp_energy -from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.srp_energy.const import CONF_IS_TOU +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME -from tests.common import MockConfigEntry +ACCNT_ID = "123456789" +ACCNT_IS_TOU = False +ACCNT_USERNAME = "abba" +ACCNT_PASSWORD = "ana" +ACCNT_NAME = "Home" -ENTRY_OPTIONS = {} - -ENTRY_CONFIG = { - CONF_NAME: "Test", - CONF_ID: "123456789", - CONF_USERNAME: "abba", - CONF_PASSWORD: "ana", - srp_energy.const.CONF_IS_TOU: False, +TEST_USER_INPUT = { + CONF_ID: ACCNT_ID, + CONF_USERNAME: ACCNT_USERNAME, + CONF_PASSWORD: ACCNT_PASSWORD, + CONF_IS_TOU: ACCNT_IS_TOU, } - -async def init_integration( - hass, - config=None, - options=None, - entry_id="1", - source=config_entries.SOURCE_USER, - side_effect=None, - usage=None, -): - """Set up the srp_energy integration in Home Assistant.""" - if not config: - config = ENTRY_CONFIG - - if not options: - options = ENTRY_OPTIONS - - config_entry = MockConfigEntry( - domain=srp_energy.SRP_ENERGY_DOMAIN, - source=source, - data=config, - options=options, - entry_id=entry_id, - ) - - with patch("srpenergy.client.SrpEnergyClient"), patch( - "homeassistant.components.srp_energy.SrpEnergyClient", side_effect=side_effect - ), patch("srpenergy.client.SrpEnergyClient.usage", return_value=usage), patch( - "homeassistant.components.srp_energy.SrpEnergyClient.usage", return_value=usage - ): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry +MOCK_USAGE = [ + ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", "1.2", "0.19"), + ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", "1.3", "0.20"), + ("7/31/2022", "02:00 AM", "2022-07-31T02:00:00", "1.1", "0.17"), + ("7/31/2022", "03:00 AM", "2022-07-31T03:00:00", "1.2", "0.18"), + ("7/31/2022", "04:00 AM", "2022-07-31T04:00:00", "0.8", "0.13"), + ("7/31/2022", "05:00 AM", "2022-07-31T05:00:00", "0.9", "0.14"), + ("7/31/2022", "06:00 AM", "2022-07-31T06:00:00", "1.6", "0.24"), + ("7/31/2022", "07:00 AM", "2022-07-31T07:00:00", "3.7", "0.53"), + ("7/31/2022", "08:00 AM", "2022-07-31T08:00:00", "1.0", "0.16"), + ("7/31/2022", "09:00 AM", "2022-07-31T09:00:00", "0.7", "0.12"), + ("7/31/2022", "10:00 AM", "2022-07-31T10:00:00", "1.9", "0.28"), + ("7/31/2022", "11:00 AM", "2022-07-31T11:00:00", "4.3", "0.61"), + ("7/31/2022", "12:00 PM", "2022-07-31T12:00:00", "2.0", "0.29"), + ("7/31/2022", "01:00 PM", "2022-07-31T13:00:00", "3.9", "0.55"), + ("7/31/2022", "02:00 PM", "2022-07-31T14:00:00", "5.3", "0.75"), + ("7/31/2022", "03:00 PM", "2022-07-31T15:00:00", "5.0", "0.70"), + ("7/31/2022", "04:00 PM", "2022-07-31T16:00:00", "2.2", "0.31"), + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", "2.6", "0.37"), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", "4.5", "0.64"), + ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", "2.5", "0.35"), + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", "2.9", "0.42"), + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", "2.2", "0.32"), + ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", "2.1", "0.30"), + ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", "2.0", "0.28"), + ("8/01/2022", "00:00 AM", "2022-08-01T00:00:00", "1.8", "0.26"), + ("8/01/2022", "01:00 AM", "2022-08-01T01:00:00", "1.7", "0.26"), + ("8/01/2022", "02:00 AM", "2022-08-01T02:00:00", "1.7", "0.26"), + ("8/01/2022", "03:00 AM", "2022-08-01T03:00:00", "0.8", "0.14"), + ("8/01/2022", "04:00 AM", "2022-08-01T04:00:00", "1.2", "0.19"), + ("8/01/2022", "05:00 AM", "2022-08-01T05:00:00", "1.6", "0.23"), + ("8/01/2022", "06:00 AM", "2022-08-01T06:00:00", "1.2", "0.18"), + ("8/01/2022", "07:00 AM", "2022-08-01T07:00:00", "3.1", "0.44"), + ("8/01/2022", "08:00 AM", "2022-08-01T08:00:00", "2.5", "0.35"), + ("8/01/2022", "09:00 AM", "2022-08-01T09:00:00", "3.3", "0.47"), + ("8/01/2022", "10:00 AM", "2022-08-01T10:00:00", "2.6", "0.37"), + ("8/01/2022", "11:00 AM", "2022-08-01T11:00:00", "0.8", "0.13"), + ("8/01/2022", "12:00 PM", "2022-08-01T12:00:00", "0.6", "0.11"), + ("8/01/2022", "01:00 PM", "2022-08-01T13:00:00", "6.4", "0.9"), + ("8/01/2022", "02:00 PM", "2022-08-01T14:00:00", "3.6", "0.52"), + ("8/01/2022", "03:00 PM", "2022-08-01T15:00:00", "5.5", "0.79"), + ("8/01/2022", "04:00 PM", "2022-08-01T16:00:00", "3", "0.43"), + ("8/01/2022", "05:00 PM", "2022-08-01T17:00:00", "5", "0.71"), + ("8/01/2022", "06:00 PM", "2022-08-01T18:00:00", "4.4", "0.63"), + ("8/01/2022", "07:00 PM", "2022-08-01T19:00:00", "3.8", "0.54"), + ("8/01/2022", "08:00 PM", "2022-08-01T20:00:00", "3.6", "0.51"), + ("8/01/2022", "09:00 PM", "2022-08-01T21:00:00", "2.9", "0.4"), + ("8/01/2022", "10:00 PM", "2022-08-01T22:00:00", "3.4", "0.49"), + ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", "2.9", "0.41"), + ("8/02/2022", "00:00 AM", "2022-08-02T00:00:00", "2", "0.3"), + ("8/02/2022", "01:00 AM", "2022-08-02T01:00:00", "2", "0.29"), + ("8/02/2022", "02:00 AM", "2022-08-02T02:00:00", "1.9", "0.28"), + ("8/02/2022", "03:00 AM", "2022-08-02T03:00:00", "1.8", "0.27"), + ("8/02/2022", "04:00 AM", "2022-08-02T04:00:00", "1.8", "0.26"), + ("8/02/2022", "05:00 AM", "2022-08-02T05:00:00", "1.6", "0.23"), + ("8/02/2022", "06:00 AM", "2022-08-02T06:00:00", "0.8", "0.14"), + ("8/02/2022", "07:00 AM", "2022-08-02T07:00:00", "4", "0.56"), + ("8/02/2022", "08:00 AM", "2022-08-02T08:00:00", "2.4", "0.34"), + ("8/02/2022", "09:00 AM", "2022-08-02T09:00:00", "4.1", "0.58"), + ("8/02/2022", "10:00 AM", "2022-08-02T10:00:00", "2.6", "0.37"), + ("8/02/2022", "11:00 AM", "2022-08-02T11:00:00", "0.5", "0.1"), + ("8/02/2022", "00:00 AM", "2022-08-02T12:00:00", "1", "0.16"), +] diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py new file mode 100644 index 00000000000..3ffebe167c2 --- /dev/null +++ b/tests/components/srp_energy/conftest.py @@ -0,0 +1,91 @@ +"""Fixtures for Srp Energy integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +import datetime as dt +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from . import MOCK_USAGE, TEST_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="setup_hass_config", autouse=True) +def fixture_setup_hass_config(hass: HomeAssistant) -> None: + """Set up things to be run when tests are started.""" + hass.config.latitude = 33.27 + hass.config.longitude = 112 + hass.config.set_time_zone(PHOENIX_TIME_ZONE) + + +@pytest.fixture(name="hass_tz_info") +def fixture_hass_tz_info(hass: HomeAssistant, setup_hass_config) -> dt.tzinfo | None: + """Return timezone info for the hass timezone.""" + return dt_util.get_time_zone(hass.config.time_zone) + + +@pytest.fixture(name="test_date") +def fixture_test_date(hass: HomeAssistant, hass_tz_info) -> dt.datetime | None: + """Return test datetime for the hass timezone.""" + test_date = dt.datetime(2022, 8, 2, 0, 0, 0, 0, tzinfo=hass_tz_info) + return test_date + + +@pytest.fixture(name="mock_config_entry") +def fixture_mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_INPUT, + ) + + +@pytest.fixture(name="mock_srp_energy") +def fixture_mock_srp_energy() -> Generator[None, MagicMock, None]: + """Return a mocked SrpEnergyClient client.""" + with patch( + "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True + ) as srp_energy_mock: + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage.return_value = MOCK_USAGE + yield client + + +@pytest.fixture(name="mock_srp_energy_config_flow") +def fixture_mock_srp_energy_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked config_flow SrpEnergyClient client.""" + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", autospec=True + ) as srp_energy_mock: + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage.return_value = MOCK_USAGE + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + test_date: dt.datetime, + mock_config_entry: MockConfigEntry, + mock_srp_energy, + mock_srp_energy_config_flow, +) -> MockConfigEntry: + """Set up the Srp Energy integration for testing.""" + + freezer.move_to(test_date) + mock_config_entry.add_to_hass(hass) + + 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/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index 8ac24573790..dfd1d41e820 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -1,120 +1,126 @@ """Test the SRP Energy config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.srp_energy.const import CONF_IS_TOU, SRP_ENERGY_DOMAIN +from homeassistant import config_entries +from homeassistant.components.srp_energy.const import CONF_IS_TOU, DOMAIN +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import ENTRY_CONFIG, init_integration +from . import ACCNT_ID, ACCNT_IS_TOU, ACCNT_PASSWORD, ACCNT_USERNAME, TEST_USER_INPUT + +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: - """Test user config.""" - # First get the form +async def test_show_form( + hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, capsys +) -> None: + """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - # Fill submit form data for config entry with patch( - "homeassistant.components.srp_energy.config_flow.SrpEnergyClient" - ), patch( "homeassistant.components.srp_energy.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, + flow_id=result["flow_id"], user_input=TEST_USER_INPUT ) - - assert result["type"] == "create_entry" - assert result["title"] == "Test" - assert result["data"][CONF_IS_TOU] is False - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test user config with invalid auth.""" - result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.srp_energy.config_flow.SrpEnergyClient.validate", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["errors"]["base"] == "invalid_auth" - - -async def test_form_value_error(hass: HomeAssistant) -> None: - """Test user config that throws a value error.""" - result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", - side_effect=ValueError(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["errors"]["base"] == "invalid_account" - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test user config that throws an unknown exception.""" - result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", - side_effect=Exception(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["errors"]["base"] == "unknown" - - -async def test_config(hass: HomeAssistant) -> None: - """Test handling of configuration imported.""" - with patch( - "homeassistant.components.srp_energy.config_flow.SrpEnergyClient" - ), patch( - "homeassistant.components.srp_energy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=ENTRY_CONFIG, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test home" + + assert "data" in result + assert result["data"][CONF_ID] == ACCNT_ID + assert result["data"][CONF_USERNAME] == ACCNT_USERNAME + assert result["data"][CONF_PASSWORD] == ACCNT_PASSWORD + assert result["data"][CONF_IS_TOU] == ACCNT_IS_TOU + + captured = capsys.readouterr() + assert "myaccount.srpnet.com" not in captured.err + + assert len(mock_setup_entry.mock_calls) == 1 -async def test_integration_already_configured(hass: HomeAssistant) -> None: - """Test integration is already configured.""" - await init_integration(hass) +async def test_form_invalid_account( + hass: HomeAssistant, + mock_srp_energy_config_flow: MagicMock, +) -> None: + """Test flow to handle invalid account error.""" + mock_srp_energy_config_flow.validate.side_effect = ValueError + result = await hass.config_entries.flow.async_init( - SRP_ENERGY_DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_account"} + + +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_srp_energy_config_flow: MagicMock, +) -> None: + """Test flow to handle invalid authentication error.""" + mock_srp_energy_config_flow.validate.return_value = False + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown_error( + hass: HomeAssistant, + mock_srp_energy_config_flow: MagicMock, +) -> None: + """Test flow to handle invalid authentication error.""" + mock_srp_energy_config_flow.validate.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_flow_entry_already_configured( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test user input for config_entry that already exists.""" + user_input = { + CONF_ID: init_integration.data[CONF_ID], + CONF_USERNAME: "abba2", + CONF_PASSWORD: "ana2", + CONF_IS_TOU: False, + } + + assert user_input[CONF_ID] == ACCNT_ID + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, data=user_input + ) + + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/srp_energy/test_init.py b/tests/components/srp_energy/test_init.py index 37305d4e105..a60dd09ea11 100644 --- a/tests/components/srp_energy/test_init.py +++ b/tests/components/srp_energy/test_init.py @@ -1,28 +1,16 @@ """Tests for Srp Energy component Init.""" -from homeassistant import config_entries -from homeassistant.components import srp_energy +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import init_integration + +async def test_setup_entry(hass: HomeAssistant, init_integration) -> None: + """Test setup entry.""" + assert init_integration.state == ConfigEntryState.LOADED -async def test_setup_entry(hass: HomeAssistant) -> None: - """Test setup entry fails if deCONZ is not available.""" - config_entry = await init_integration(hass) - assert config_entry.state == config_entries.ConfigEntryState.LOADED - assert hass.data[srp_energy.SRP_ENERGY_DOMAIN] - - -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(hass: HomeAssistant, init_integration) -> None: """Test being able to unload an entry.""" - config_entry = await init_integration(hass) - assert hass.data[srp_energy.SRP_ENERGY_DOMAIN] + assert init_integration.state == ConfigEntryState.LOADED - assert await srp_energy.async_unload_entry(hass, config_entry) - assert not hass.data[srp_energy.SRP_ENERGY_DOMAIN] - - -async def test_async_setup_entry_with_exception(hass: HomeAssistant) -> None: - """Test exception when SrpClient can't load.""" - await init_integration(hass, side_effect=Exception()) - assert srp_energy.SRP_ENERGY_DOMAIN not in hass.data + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 44930886065..3310e9ce9cd 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -1,137 +1,39 @@ """Tests for the srp_energy sensor platform.""" -from unittest.mock import MagicMock - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.srp_energy.const import ( - ATTRIBUTION, - DEFAULT_NAME, - ICON, - SENSOR_NAME, - SENSOR_TYPE, - SRP_ENERGY_DOMAIN, +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + UnitOfEnergy, ) -from homeassistant.components.srp_energy.sensor import SrpEntity, async_setup_entry -from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -async def test_async_setup_entry(hass: HomeAssistant) -> None: - """Test the sensor.""" - fake_async_add_entities = MagicMock() - fake_srp_energy_client = MagicMock() - fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] - fake_config = MagicMock( - data={ - "name": "SRP Energy", - "is_tou": False, - "id": "0123456789", - "username": "testuser@example.com", - "password": "mypassword", - } +async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: + """Test the srp energy sensors.""" + # Validate the Config Entry was initialized + assert init_integration.state == ConfigEntryState.LOADED + + # Check sensors were loaded + assert len(hass.states.async_all()) == 1 + + +async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: + """Test the SrpEntity.""" + usage_state = hass.states.get("sensor.home_energy_usage") + assert usage_state.state == "150.8" + + # Validate attributions + assert ( + usage_state.attributes.get("state_class") is SensorStateClass.TOTAL_INCREASING ) - hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client - - await async_setup_entry(hass, fake_config, fake_async_add_entities) - - -async def test_async_setup_entry_timeout_error(hass: HomeAssistant) -> None: - """Test fetching usage data. Failed the first time because was too get response.""" - fake_async_add_entities = MagicMock() - fake_srp_energy_client = MagicMock() - fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] - fake_config = MagicMock( - data={ - "name": "SRP Energy", - "is_tou": False, - "id": "0123456789", - "username": "testuser@example.com", - "password": "mypassword", - } + assert usage_state.attributes.get(ATTR_ATTRIBUTION) == "Powered by SRP Energy" + assert ( + usage_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfEnergy.KILO_WATT_HOUR ) - hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client - fake_srp_energy_client.usage.side_effect = TimeoutError() - await async_setup_entry(hass, fake_config, fake_async_add_entities) - assert not fake_async_add_entities.call_args[0][0][ - 0 - ].coordinator.last_update_success - - -async def test_async_setup_entry_connect_error(hass: HomeAssistant) -> None: - """Test fetching usage data. Failed the first time because was too get response.""" - fake_async_add_entities = MagicMock() - fake_srp_energy_client = MagicMock() - fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] - fake_config = MagicMock( - data={ - "name": "SRP Energy", - "is_tou": False, - "id": "0123456789", - "username": "testuser@example.com", - "password": "mypassword", - } - ) - hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client - fake_srp_energy_client.usage.side_effect = ValueError() - - await async_setup_entry(hass, fake_config, fake_async_add_entities) - assert not fake_async_add_entities.call_args[0][0][ - 0 - ].coordinator.last_update_success - - -async def test_srp_entity(hass: HomeAssistant) -> None: - """Test the SrpEntity.""" - fake_coordinator = MagicMock(data=1.99999999999) - srp_entity = SrpEntity(fake_coordinator) - srp_entity.hass = hass - - assert srp_entity is not None - assert srp_entity.name == f"{DEFAULT_NAME} {SENSOR_NAME}" - assert srp_entity.unique_id == SENSOR_TYPE - assert srp_entity.state is None - assert srp_entity.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR - assert srp_entity.icon == ICON - assert srp_entity.usage == "2.00" - assert srp_entity.should_poll is False - assert srp_entity.attribution == ATTRIBUTION - assert srp_entity.available is not None - assert srp_entity.device_class is SensorDeviceClass.ENERGY - assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING - - await srp_entity.async_added_to_hass() - assert srp_entity.state is not None - assert fake_coordinator.async_add_listener.called - assert not fake_coordinator.async_add_listener.data.called - - -async def test_srp_entity_no_data(hass: HomeAssistant) -> None: - """Test the SrpEntity.""" - fake_coordinator = MagicMock(data=False) - srp_entity = SrpEntity(fake_coordinator) - srp_entity.hass = hass - assert srp_entity.extra_state_attributes is None - - -async def test_srp_entity_no_coord_data(hass: HomeAssistant) -> None: - """Test the SrpEntity.""" - fake_coordinator = MagicMock(data=False) - srp_entity = SrpEntity(fake_coordinator) - srp_entity.hass = hass - - assert srp_entity.usage is None - - -async def test_srp_entity_async_update(hass: HomeAssistant) -> None: - """Test the SrpEntity.""" - - async def async_magic(): - pass - - MagicMock.__await__ = lambda x: async_magic().__await__() - fake_coordinator = MagicMock(data=False) - srp_entity = SrpEntity(fake_coordinator) - srp_entity.hass = hass - - await srp_entity.async_update() - assert fake_coordinator.async_request_refresh.called + assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" diff --git a/tests/components/stookalert/test_config_flow.py b/tests/components/stookalert/test_config_flow.py index aeff4b01de9..0014f4e5ad5 100644 --- a/tests/components/stookalert/test_config_flow.py +++ b/tests/components/stookalert/test_config_flow.py @@ -16,7 +16,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" with patch( "homeassistant.components.stookalert.async_setup_entry", return_value=True diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index b18eb54b322..90786659254 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -15,7 +15,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" assert "flow_id" in result with patch( diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 033c47a5e73..b8c27037a25 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -3,6 +3,7 @@ import asyncio from datetime import timedelta from io import BytesIO import os +from pathlib import Path from unittest.mock import patch import av @@ -39,9 +40,9 @@ async def stream_component(hass): @pytest.fixture -def filename(tmpdir): +def filename(tmp_path: Path) -> str: """Use this filename for the tests.""" - return f"{tmpdir}/test.mp4" + return str(tmp_path / "test.mp4") async def test_record_stream(hass: HomeAssistant, filename, h264_video) -> None: diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 3b9f53be116..0dc67c37403 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -17,6 +17,7 @@ import fractions import io import logging import math +from pathlib import Path import threading from unittest.mock import patch @@ -75,9 +76,9 @@ TIMEOUT = 15 @pytest.fixture -def filename(tmpdir): +def filename(tmp_path: Path) -> str: """Use this filename for the tests.""" - return f"{tmpdir}/test.mp4" + return str(tmp_path / "test.mp4") @pytest.fixture(autouse=True) diff --git a/tests/components/stt/common.py b/tests/components/stt/common.py new file mode 100644 index 00000000000..79b58531b54 --- /dev/null +++ b/tests/components/stt/common.py @@ -0,0 +1,74 @@ +"""Provide common test tools for STT.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from pathlib import Path +from typing import Any + +from homeassistant.components.stt import Provider +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from tests.common import MockPlatform, mock_platform + + +class MockSTTPlatform(MockPlatform): + """Help to set up test stt service.""" + + def __init__( + self, + async_get_engine: Callable[ + [HomeAssistant, ConfigType, DiscoveryInfoType | None], + Coroutine[Any, Any, Provider | None], + ] + | None = None, + get_engine: Callable[ + [HomeAssistant, ConfigType, DiscoveryInfoType | None], Provider | None + ] + | None = None, + ) -> None: + """Return the stt service.""" + super().__init__() + if get_engine: + self.get_engine = get_engine + if async_get_engine: + self.async_get_engine = async_get_engine + + +def mock_stt_platform( + hass: HomeAssistant, + tmp_path: Path, + integration: str = "stt", + async_get_engine: Callable[ + [HomeAssistant, ConfigType, DiscoveryInfoType | None], + Coroutine[Any, Any, Provider | None], + ] + | None = None, + get_engine: Callable[ + [HomeAssistant, ConfigType, DiscoveryInfoType | None], Provider | None + ] + | None = None, +): + """Specialize the mock platform for stt.""" + loaded_platform = MockSTTPlatform(async_get_engine, get_engine) + mock_platform(hass, f"{integration}.stt", loaded_platform) + + return loaded_platform + + +def mock_stt_entity_platform( + hass: HomeAssistant, + tmp_path: Path, + integration: str, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry, AddEntitiesCallback], + Coroutine[Any, Any, None], + ] + | None = None, +) -> MockPlatform: + """Specialize the mock platform for stt.""" + loaded_platform = MockPlatform(async_setup_entry=async_setup_entry) + mock_platform(hass, f"{integration}.stt", loaded_platform) + return loaded_platform diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 3d20dbc5403..5a7e93a72a2 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -1,11 +1,13 @@ """Test STT component setup.""" -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Generator from http import HTTPStatus -from unittest.mock import AsyncMock, Mock +from pathlib import Path +from unittest.mock import AsyncMock import pytest from homeassistant.components.stt import ( + DOMAIN, AudioBitRates, AudioChannels, AudioCodecs, @@ -15,28 +17,44 @@ from homeassistant.components.stt import ( SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, + async_default_engine, async_get_provider, + async_get_speech_to_text_engine, ) -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component -from tests.common import mock_platform -from tests.typing import ClientSessionGenerator +from .common import mock_stt_entity_platform, mock_stt_platform + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +TEST_DOMAIN = "test" -class MockProvider(Provider): +class BaseProvider: """Mock provider.""" fail_process_audio = False def __init__(self) -> None: """Init test provider.""" - self.calls = [] + self.calls: list[tuple[SpeechMetadata, AsyncIterable[bytes]]] = [] @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return ["en"] + return ["de", "de-CH", "en"] @property def supported_formats(self) -> list[AudioFormats]: @@ -71,7 +89,20 @@ class MockProvider(Provider): if self.fail_process_audio: return SpeechResult(None, SpeechResultState.ERROR) - return SpeechResult("test", SpeechResultState.SUCCESS) + return SpeechResult("test_result", SpeechResultState.SUCCESS) + + +class MockProvider(BaseProvider, Provider): + """Mock provider.""" + + url_path = TEST_DOMAIN + + +class MockProviderEntity(BaseProvider, SpeechToTextEntity): + """Mock provider entity.""" + + url_path = "stt.test" + _attr_name = "test" @pytest.fixture @@ -80,24 +111,120 @@ def mock_provider() -> MockProvider: return MockProvider() +@pytest.fixture +def mock_provider_entity() -> MockProviderEntity: + """Test provider entity fixture.""" + return MockProviderEntity() + + +class STTFlow(ConfigFlow): + """Test flow.""" + + @pytest.fixture(autouse=True) -async def mock_setup(hass: HomeAssistant, mock_provider: MockProvider) -> None: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, STTFlow): + yield + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + tmp_path: Path, + request: pytest.FixtureRequest, +) -> MockProvider | MockProviderEntity: + """Set up the test environment.""" + if request.param == "mock_setup": + provider = MockProvider() + await mock_setup(hass, tmp_path, provider) + elif request.param == "mock_config_entry_setup": + provider = MockProviderEntity() + await mock_config_entry_setup(hass, tmp_path, provider) + else: + raise RuntimeError("Invalid setup fixture") + + return provider + + +async def mock_setup( + hass: HomeAssistant, + tmp_path: Path, + mock_provider: MockProvider, +) -> None: """Set up a test provider.""" - mock_platform( - hass, "test.stt", Mock(async_get_engine=AsyncMock(return_value=mock_provider)) + mock_stt_platform( + hass, + tmp_path, + TEST_DOMAIN, + async_get_engine=AsyncMock(return_value=mock_provider), ) - assert await async_setup_component(hass, "stt", {"stt": {"platform": "test"}}) + assert await async_setup_component(hass, "stt", {"stt": {"platform": TEST_DOMAIN}}) + await hass.async_block_till_done() +async def mock_config_entry_setup( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> MockConfigEntry: + """Set up a test provider via config entry.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([mock_provider_entity]) + + mock_stt_entity_platform(hass, tmp_path, TEST_DOMAIN, async_setup_entry_platform) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) async def test_get_provider_info( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: MockProvider | MockProviderEntity, ) -> None: """Test engine that doesn't exist.""" client = await hass_client() - response = await client.get("/api/stt/test") + response = await client.get(f"/api/stt/{setup.url_path}") assert response.status == HTTPStatus.OK assert await response.json() == { - "languages": ["en"], + "languages": ["de", "de-CH", "en"], "formats": ["wav", "ogg"], "codecs": ["pcm", "opus"], "sample_rates": [16000], @@ -106,22 +233,44 @@ async def test_get_provider_info( } -async def test_get_non_existing_provider_info( - hass: HomeAssistant, hass_client: ClientSessionGenerator +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) +async def test_non_existing_provider( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: MockProvider | MockProviderEntity, ) -> None: """Test streaming to engine that doesn't exist.""" client = await hass_client() + response = await client.get("/api/stt/not_exist") assert response.status == HTTPStatus.NOT_FOUND + response = await client.post( + "/api/stt/not_exist", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" + " language=en" + ) + }, + ) + assert response.status == HTTPStatus.NOT_FOUND + +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) async def test_stream_audio( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: MockProvider | MockProviderEntity, ) -> None: """Test streaming audio and getting response.""" client = await hass_client() response = await client.post( - "/api/stt/test", + f"/api/stt/{setup.url_path}", headers={ "X-Speech-Content": ( "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" @@ -130,20 +279,39 @@ async def test_stream_audio( }, ) assert response.status == HTTPStatus.OK - assert await response.json() == {"text": "test", "result": "success"} + assert await response.json() == {"text": "test_result", "result": "success"} +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) @pytest.mark.parametrize( ("header", "status", "error"), ( (None, 400, "Missing X-Speech-Content header"), + ( + ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=100;" + " language=en; unknown=1" + ), + 400, + "Invalid field: unknown", + ), ( ( "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=100;" " language=en" ), 400, - "100 is not a valid AudioChannels", + "Wrong format of X-Speech-Content: 100 is not a valid AudioChannels", + ), + ( + ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=bad channel;" + " language=en" + ), + 400, + "Wrong format of X-Speech-Content: invalid literal for int() with base 10: 'bad channel'", ), ( "format=wav; codec=pcm; sample_rate=16000", @@ -158,6 +326,7 @@ async def test_metadata_errors( header: str | None, status: int, error: str, + setup: MockProvider | MockProviderEntity, ) -> None: """Test metadata errors.""" client = await hass_client() @@ -165,11 +334,202 @@ async def test_metadata_errors( if header: headers["X-Speech-Content"] = header - response = await client.post("/api/stt/test", headers=headers) + response = await client.post(f"/api/stt/{setup.url_path}", headers=headers) assert response.status == status assert await response.text() == error -async def test_get_provider(hass: HomeAssistant, mock_provider: MockProvider) -> None: +async def test_get_provider( + hass: HomeAssistant, + tmp_path: Path, + mock_provider: MockProvider, +) -> None: """Test we can get STT providers.""" - assert mock_provider == async_get_provider(hass, "test") + await mock_setup(hass, tmp_path, mock_provider) + assert mock_provider == async_get_provider(hass, TEST_DOMAIN) + + # Test getting the default provider + assert mock_provider == async_get_provider(hass) + + +async def test_config_entry_unload( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test we can unload config entry.""" + config_entry = await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + assert config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_restore_state( + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [("mock_setup", "test"), ("mock_config_entry_setup", "stt.test")], + indirect=["setup"], +) +async def test_ws_list_engines( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup: MockProvider | MockProviderEntity, + engine_id: str, +) -> None: + """Test listing speech to text engines.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "stt/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + {"engine_id": engine_id, "supported_languages": ["de", "de-CH", "en"]} + ] + } + + await client.send_json_auto_id({"type": "stt/engine/list", "language": "smurfish"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": []}] + } + + await client.send_json_auto_id({"type": "stt/engine/list", "language": "en"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": ["en"]}] + } + + await client.send_json_auto_id({"type": "stt/engine/list", "language": "en-UK"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": ["en"]}] + } + + await client.send_json_auto_id({"type": "stt/engine/list", "language": "de"}) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": ["de", "de-CH"]}] + } + + await client.send_json_auto_id( + {"type": "stt/engine/list", "language": "de", "country": "ch"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": ["de-CH", "de"]}] + } + + +async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: + """Test async_default_engine.""" + assert await async_setup_component(hass, "stt", {"stt": {}}) + await hass.async_block_till_done() + + assert async_default_engine(hass) is None + + +async def test_default_engine(hass: HomeAssistant, tmp_path: Path) -> None: + """Test async_default_engine.""" + mock_stt_platform( + hass, + tmp_path, + TEST_DOMAIN, + async_get_engine=AsyncMock(return_value=mock_provider), + ) + assert await async_setup_component(hass, "stt", {"stt": {"platform": TEST_DOMAIN}}) + await hass.async_block_till_done() + + assert async_default_engine(hass) == TEST_DOMAIN + + +async def test_default_engine_entity( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test async_default_engine.""" + await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + + assert async_default_engine(hass) == f"{DOMAIN}.{TEST_DOMAIN}" + + +async def test_default_engine_prefer_cloud(hass: HomeAssistant, tmp_path: Path) -> None: + """Test async_default_engine.""" + mock_stt_platform( + hass, + tmp_path, + TEST_DOMAIN, + async_get_engine=AsyncMock(return_value=mock_provider), + ) + mock_stt_platform( + hass, + tmp_path, + "cloud", + async_get_engine=AsyncMock(return_value=mock_provider), + ) + assert await async_setup_component( + hass, "stt", {"stt": [{"platform": TEST_DOMAIN}, {"platform": "cloud"}]} + ) + await hass.async_block_till_done() + + assert async_default_engine(hass) == "cloud" + + +async def test_get_engine_legacy( + hass: HomeAssistant, tmp_path: Path, mock_provider: MockProvider +) -> None: + """Test async_get_speech_to_text_engine.""" + mock_stt_platform( + hass, + tmp_path, + TEST_DOMAIN, + async_get_engine=AsyncMock(return_value=mock_provider), + ) + mock_stt_platform( + hass, + tmp_path, + "cloud", + async_get_engine=AsyncMock(return_value=mock_provider), + ) + assert await async_setup_component( + hass, "stt", {"stt": [{"platform": TEST_DOMAIN}, {"platform": "cloud"}]} + ) + await hass.async_block_till_done() + + assert async_get_speech_to_text_engine(hass, "no_such_provider") is None + assert async_get_speech_to_text_engine(hass, "test") is mock_provider + + +async def test_get_engine_entity( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test async_get_speech_to_text_engine.""" + await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + + assert async_get_speech_to_text_engine(hass, "stt.test") is mock_provider_entity diff --git a/tests/components/stt/test_legacy.py b/tests/components/stt/test_legacy.py new file mode 100644 index 00000000000..a95a1f0f6f4 --- /dev/null +++ b/tests/components/stt/test_legacy.py @@ -0,0 +1,56 @@ +"""Test the legacy stt setup.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from homeassistant.components.stt import Provider +from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .common import mock_stt_platform + + +async def test_invalid_platform( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test platform setup with an invalid platform.""" + await async_load_platform( + hass, + "stt", + "bad_stt", + {"stt": [{"platform": "bad_stt"}]}, + hass_config={"stt": [{"platform": "bad_stt"}]}, + ) + await hass.async_block_till_done() + + assert "Unknown speech to text platform specified" in caplog.text + + +async def test_platform_setup_with_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test platform setup with an error during setup.""" + + async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> Provider: + """Raise exception during platform setup.""" + raise Exception("Setup error") # pylint: disable=broad-exception-raised + + mock_stt_platform(hass, tmp_path, "bad_stt", async_get_engine=async_get_engine) + + await async_load_platform( + hass, + "stt", + "bad_stt", + {}, + hass_config={"stt": [{"platform": "bad_stt"}]}, + ) + await hass.async_block_till_done() + + assert "Error setting up platform: bad_stt" in caplog.text diff --git a/tests/components/sun/test_config_flow.py b/tests/components/sun/test_config_flow.py index 2d4e2d83249..2bf577f82b8 100644 --- a/tests/components/sun/test_config_flow.py +++ b/tests/components/sun/test_config_flow.py @@ -18,7 +18,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" with patch( "homeassistant.components.sun.async_setup_entry", diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index c795a59a8e2..e24f404a34b 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -35,7 +35,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/switch/test_light.py b/tests/components/switch/test_light.py index a77ee314088..2254abc08f9 100644 --- a/tests/components/switch/test_light.py +++ b/tests/components/switch/test_light.py @@ -1,4 +1,6 @@ """The tests for the Light Switch platform.""" +import pytest + from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_SUPPORTED_COLOR_MODES, @@ -12,6 +14,12 @@ from . import common as switch_common from tests.components.light import common +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_default_state(hass: HomeAssistant) -> None: """Test light switch default state.""" await async_setup_component( diff --git a/tests/components/switch_as_x/conftest.py b/tests/components/switch_as_x/conftest.py index f722292fc89..d324f7a0c54 100644 --- a/tests/components/switch_as_x/conftest.py +++ b/tests/components/switch_as_x/conftest.py @@ -6,6 +6,15 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 87cc291a599..fac744d0c0e 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN from homeassistant.const import ( CONF_ENTITY_ID, @@ -19,9 +20,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +EXPOSE_SETTINGS = { + "cloud.alexa": True, + "cloud.google_assistant": False, + "conversation": True, +} + PLATFORMS_TO_TEST = ( Platform.COVER, Platform.FAN, @@ -607,7 +615,7 @@ async def test_custom_name_2( ) -> None: """Test the source entity has a custom name. - This tests the custom name is only copied from the source device when the config + This tests the custom name is only copied from the source device when the switch_as_x config entry is setup the first time. """ registry = er.async_get(hass) @@ -647,6 +655,8 @@ async def test_custom_name_2( ) switch_as_x_config_entry.add_to_hass(hass) + # Register the switch as x entity in the entity registry, this means + # the entity has been setup before switch_as_x_entity_entry = registry.async_get_or_create( target_domain, "switch_as_x", @@ -674,3 +684,183 @@ async def test_custom_name_2( assert entity_entry.options == { DOMAIN: {"entity_id": switch_entity_entry.entity_id} } + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_import_expose_settings_1( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test importing assistant expose settings.""" + await async_setup_component(hass, "homeassistant", {}) + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + original_name="ABC", + ) + for assistant, should_expose in EXPOSE_SETTINGS.items(): + exposed_entities.async_expose_entity( + hass, assistant, switch_entity_entry.entity_id, should_expose + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = registry.async_get(f"{target_domain}.abc") + assert entity_entry + + # Check switch_as_x expose settings were copied from the switch + expose_settings = exposed_entities.async_get_entity_settings( + hass, entity_entry.entity_id + ) + for assistant in EXPOSE_SETTINGS: + assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + + # Check the switch is no longer exposed + expose_settings = exposed_entities.async_get_entity_settings( + hass, switch_entity_entry.entity_id + ) + for assistant in EXPOSE_SETTINGS: + assert expose_settings[assistant]["should_expose"] is False + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_import_expose_settings_2( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test importing assistant expose settings. + + This tests the expose settings are only copied from the source device when the + switch_as_x config entry is setup the first time. + """ + + await async_setup_component(hass, "homeassistant", {}) + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + original_name="ABC", + ) + for assistant, should_expose in EXPOSE_SETTINGS.items(): + exposed_entities.async_expose_entity( + hass, assistant, switch_entity_entry.entity_id, should_expose + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + # Register the switch as x entity in the entity registry, this means + # the entity has been setup before + switch_as_x_entity_entry = registry.async_get_or_create( + target_domain, + "switch_as_x", + switch_as_x_config_entry.entry_id, + suggested_object_id="abc", + ) + for assistant, should_expose in EXPOSE_SETTINGS.items(): + exposed_entities.async_expose_entity( + hass, assistant, switch_as_x_entity_entry.entity_id, not should_expose + ) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = registry.async_get(f"{target_domain}.abc") + assert entity_entry + + # Check switch_as_x expose settings were not copied from the switch + expose_settings = exposed_entities.async_get_entity_settings( + hass, entity_entry.entity_id + ) + for assistant in EXPOSE_SETTINGS: + assert ( + expose_settings[assistant]["should_expose"] + is not EXPOSE_SETTINGS[assistant] + ) + + # Check the switch settings were not modified + expose_settings = exposed_entities.async_get_entity_settings( + hass, switch_entity_entry.entity_id + ) + for assistant in EXPOSE_SETTINGS: + assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_restore_expose_settings( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test removing a config entry restores assistant expose settings.""" + await async_setup_component(hass, "homeassistant", {}) + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + original_name="ABC", + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + # Register the switch as x entity + switch_as_x_entity_entry = registry.async_get_or_create( + target_domain, + "switch_as_x", + switch_as_x_config_entry.entry_id, + config_entry=switch_as_x_config_entry, + suggested_object_id="abc", + ) + for assistant, should_expose in EXPOSE_SETTINGS.items(): + exposed_entities.async_expose_entity( + hass, assistant, switch_as_x_entity_entry.entity_id, should_expose + ) + + # Remove the config entry + assert await hass.config_entries.async_remove(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + # Check the switch expose settings were restored + expose_settings = exposed_entities.async_get_entity_settings( + hass, switch_entity_entry.entity_id + ) + for assistant in EXPOSE_SETTINGS: + assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index d6f754c390b..77ef1b61e9e 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -4,6 +4,9 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -14,6 +17,12 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup +@pytest.fixture +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + @pytest.fixture(name="mock_dsm") def fixture_dsm(): """Set up SynologyDSM API fixture.""" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3852712f70d..ef4dee7c597 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -512,7 +512,7 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "wrong_host", + CONF_HOST: "192.168.1.3", CONF_VERIFY_SSL: VERIFY_SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, @@ -539,14 +539,24 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: @pytest.mark.usefixtures("mock_setup_entry") -async def test_skip_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: +@pytest.mark.parametrize( + ("current_host", "new_host"), + [ + ("some.fqdn", "192.168.1.5"), + ("192.168.1.5", "abcd:1234::"), + ("abcd:1234::", "192.168.1.5"), + ], +) +async def test_skip_reconfig_ssdp( + hass: HomeAssistant, current_host: str, new_host: str, service: MagicMock +) -> None: """Test re-configuration of already existing entry by ssdp.""" MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "wrong_host", - CONF_VERIFY_SSL: True, + CONF_HOST: current_host, + CONF_VERIFY_SSL: VERIFY_SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_MAC: MACS, @@ -560,7 +570,7 @@ async def test_skip_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> No data=ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", - ssdp_location="http://192.168.1.5:5000", + ssdp_location=f"http://{new_host}:5000", upnp={ ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py new file mode 100644 index 00000000000..e0aadf9260c --- /dev/null +++ b/tests/components/synology_dsm/test_media_source.py @@ -0,0 +1,412 @@ +"""Tests for Synology DSM Media Source.""" + +from pathlib import Path +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem +from synology_dsm.exceptions import SynologyDSMException + +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMedia, + MediaSourceItem, + Unresolvable, +) +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.components.synology_dsm.media_source import ( + SynologyDsmMediaView, + SynologyPhotosMediaSource, + async_get_media_source, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.aiohttp import MockRequest, web + +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def dsm_with_photos() -> MagicMock: + """Set up SynologyDSM API fixture.""" + dsm = MagicMock() + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + dsm.network.update = AsyncMock(return_value=True) + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + + dsm.photos.get_albums = AsyncMock(return_value=[SynoPhotosAlbum(1, "Album 1", 10)]) + dsm.photos.get_items_from_album = AsyncMock( + return_value=[SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm")] + ) + dsm.photos.get_item_thumbnail_url = AsyncMock( + return_value="http://my.thumbnail.url" + ) + return dsm + + +@pytest.mark.usefixtures("setup_media_source") +async def test_get_media_source(hass: HomeAssistant) -> None: + """Test the async_get_media_source function and SynologyPhotosMediaSource constructor.""" + + source = await async_get_media_source(hass) + assert isinstance(source, SynologyPhotosMediaSource) + assert source.domain == DOMAIN + + +@pytest.mark.usefixtures("setup_media_source") +@pytest.mark.parametrize( + ("identifier", "exception_msg"), + [ + ("unique_id", "No album id"), + ("unique_id/1", "No file name"), + ("unique_id/1/cache_key", "No file name"), + ("unique_id/1/cache_key/filename", "No file extension"), + ], +) +async def test_resolve_media_bad_identifier( + hass: HomeAssistant, identifier: str, exception_msg: str +) -> None: + """Test resolve_media with bad identifiers.""" + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + with pytest.raises(Unresolvable, match=exception_msg): + await source.async_resolve_media(item) + + +@pytest.mark.usefixtures("setup_media_source") +@pytest.mark.parametrize( + ("identifier", "url", "mime_type"), + [ + ( + "ABC012345/10/27643_876876/filename.jpg", + "/synology_dsm/ABC012345/27643_876876/filename.jpg", + "image/jpeg", + ), + ( + "ABC012345/12/12631_47189/filename.png", + "/synology_dsm/ABC012345/12631_47189/filename.png", + "image/png", + ), + ], +) +async def test_resolve_media_success( + hass: HomeAssistant, identifier: str, url: str, mime_type: str +) -> None: + """Test successful resolving an item.""" + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + result = await source.async_resolve_media(item) + + assert result.url == url + assert result.mime_type == mime_type + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: + """Test browse_media without any devices being configured.""" + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, "unique_id/album_id/cache_key/filename.jpg", None + ) + with pytest.raises(BrowseError, match="Diskstation not initialized"): + await source.async_browse_media(item) + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_album_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media with unknown album.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + # exception in get_albums() + dsm_with_photos.photos.get_albums = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, entry.unique_id, None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_root( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning root media sources.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + assert isinstance(result.children[0], BrowseMedia) + assert result.children[0].identifier == "mocked_syno_dsm_entry" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_albums( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 2 + assert isinstance(result.children[0], BrowseMedia) + assert result.children[0].identifier == "mocked_syno_dsm_entry/0" + assert result.children[0].title == "All images" + assert isinstance(result.children[1], BrowseMedia) + assert result.children[1].identifier == "mocked_syno_dsm_entry/1" + assert result.children[1].title == "Album 1" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + # unknown album + dsm_with_photos.photos.get_items_from_album = AsyncMock(return_value=[]) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + # exception in get_items_from_album() + dsm_with_photos.photos.get_items_from_album = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items_thumbnail_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + dsm_with_photos.photos.get_item_thumbnail_url = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + item = result.children[0] + assert isinstance(item, BrowseMedia) + assert item.thumbnail is None + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + item = result.children[0] + assert isinstance(item, BrowseMedia) + assert item.identifier == "mocked_syno_dsm_entry/1/10_1298753/filename.jpg" + assert item.title == "filename.jpg" + assert item.media_class == MediaClass.IMAGE + assert item.media_content_type == "image/jpeg" + assert item.can_play + assert not item.can_expand + assert item.thumbnail == "http://my.thumbnail.url" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_media_view( + hass: HomeAssistant, tmp_path: Path, dsm_with_photos: MagicMock +) -> None: + """Test SynologyDsmMediaView returning albums.""" + view = SynologyDsmMediaView(hass) + request = MockRequest(b"", DOMAIN) + + # diskation not set uped + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "") + + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "10_1298753/filename") + + # exception in download_item() + dsm_with_photos.photos.download_item = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + with pytest.raises(web.HTTPNotFound): + await view.get(request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg") + + # success + dsm_with_photos.photos.download_item = AsyncMock(return_value=b"xxxx") + tempfile.tempdir = tmp_path + result = await view.get(request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg") + assert isinstance(result, web.Response) diff --git a/tests/components/tailscale/test_config_flow.py b/tests/components/tailscale/test_config_flow.py index 45e3e85d878..5bf814a56d6 100644 --- a/tests/components/tailscale/test_config_flow.py +++ b/tests/components/tailscale/test_config_flow.py @@ -24,7 +24,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -60,7 +60,7 @@ async def test_full_flow_with_authentication_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_tailscale_config_flow.devices.side_effect = TailscaleAuthenticationError result2 = await hass.config_entries.flow.async_configure( @@ -72,7 +72,7 @@ async def test_full_flow_with_authentication_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"base": "invalid_auth"} assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index adc41fe717b..fefad59aa08 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -1,4 +1,6 @@ """The tests for the Template cover platform.""" +from typing import Any + import pytest from homeassistant import setup @@ -149,6 +151,72 @@ async def test_template_state_text( assert text in caplog.text +@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize( + ("config", "entity", "set_state", "test_state", "attr"), + [ + ( + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **OPEN_CLOSE_COVER_CONFIG, + "position_template": ( + "{{ states.cover.test.attributes.position }}" + ), + "value_template": "{{ states.cover.test_state.state }}", + } + }, + } + }, + "cover.test_state", + "", + STATE_UNKNOWN, + {}, + ), + ( + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **OPEN_CLOSE_COVER_CONFIG, + "position_template": ( + "{{ states.cover.test.attributes.position }}" + ), + "value_template": "{{ states.cover.test_state.state }}", + } + }, + } + }, + "cover.test_state", + None, + STATE_UNKNOWN, + {}, + ), + ], +) +async def test_template_state_text_ignored_if_none_or_empty( + hass: HomeAssistant, + entity: str, + set_state: str, + test_state: str, + attr: dict[str, Any], + start_ha, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ignoring an empty state text of a template.""" + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_UNKNOWN + + hass.states.async_set(entity, set_state, attributes=attr) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == test_state + assert "ERROR" not in caplog.text + + @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) @pytest.mark.parametrize( "config", @@ -191,7 +259,9 @@ async def test_template_state_boolean(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_template_position(hass: HomeAssistant, start_ha) -> None: +async def test_template_position( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: """Test the position_template attribute.""" hass.states.async_set("cover.test", STATE_OPEN) attrs = {} @@ -199,6 +269,7 @@ async def test_template_position(hass: HomeAssistant, start_ha) -> None: for set_state, pos, test_state in [ (STATE_CLOSED, 42, STATE_OPEN), (STATE_OPEN, 0.0, STATE_CLOSED), + (STATE_CLOSED, None, STATE_UNKNOWN), ]: attrs["position"] = pos hass.states.async_set("cover.test", set_state, attributes=attrs) @@ -206,6 +277,7 @@ async def test_template_position(hass: HomeAssistant, start_ha) -> None: state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") == pos assert state.state == test_state + assert "ValueError" not in caplog.text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) @@ -233,26 +305,46 @@ async def test_template_not_optimistic(hass: HomeAssistant, start_ha) -> None: @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) @pytest.mark.parametrize( - "config", + ("config", "tilt_position"), [ - { - DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ 42 }}", - } - }, - } - }, + ( + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **OPEN_CLOSE_COVER_CONFIG, + "value_template": "{{ 1 == 1 }}", + "tilt_template": "{{ 42 }}", + } + }, + } + }, + 42.0, + ), + ( + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **OPEN_CLOSE_COVER_CONFIG, + "value_template": "{{ 1 == 1 }}", + "tilt_template": "{{ None }}", + } + }, + } + }, + None, + ), ], ) -async def test_template_tilt(hass: HomeAssistant, start_ha) -> None: +async def test_template_tilt( + hass: HomeAssistant, tilt_position: float | None, start_ha +) -> None: """Test the tilt_template attribute.""" state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == 42.0 + assert state.attributes.get("current_tilt_position") == tilt_position @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py index b62baaac818..54134ee501a 100644 --- a/tests/components/text/test_recorder.py +++ b/tests/components/text/test_recorder.py @@ -19,13 +19,16 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, text.DOMAIN, {text.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index d967c3dc035..75c07be7ec3 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,6 +1,6 @@ """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 @@ -24,6 +24,8 @@ from homeassistant.util import dt from tests.typing import ClientSessionGenerator +SUMMARY = "A task" + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): @@ -33,19 +35,25 @@ def set_time_zone(hass: HomeAssistant): hass.config.set_time_zone("America/Regina") +@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") + + @pytest.fixture(name="task") -def mock_task() -> Task: +def mock_task(due: Due) -> Task: """Mock a todoist Task instance.""" return Task( assignee_id="1", assigner_id="1", comment_count=0, is_completed=False, - content="A task", + content=SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today"), + due=due, id="1", labels=["Label1"], order=1, @@ -93,104 +101,91 @@ def get_events_url(entity: str, start: str, end: str) -> str: return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +def get_events_response(start: dict[str, str], end: dict[str, str]) -> dict[str, Any]: + """Return an event response with a single task.""" + return { + "start": start, + "end": end, + "summary": SUMMARY, + "description": None, + "location": None, + "uid": None, + "recurrence_id": None, + "rrule": None, + } + + +@pytest.fixture(name="todoist_config") +def mock_todoist_config() -> dict[str, Any]: + """Mock todoist configuration.""" + return {} + + +@pytest.fixture(name="setup_integration", autouse=True) +async def mock_setup_integration( + hass: HomeAssistant, + api: AsyncMock, + todoist_config: dict[str, Any], +) -> None: + """Mock setup of the todoist integration.""" + with patch( + "homeassistant.components.todoist.calendar.TodoistAPIAsync" + ) as todoist_api: + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + **todoist_config, + } + }, + ) + await hass.async_block_till_done() + await async_update_entity(hass, "calendar.name") + yield + + async def test_calendar_entity_unique_id( - todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry + hass: HomeAssistant, api: AsyncMock, entity_registry: er.EntityRegistry ) -> None: """Test unique id is set to project id.""" - todoist_api.return_value = api - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - } - }, - ) - await hass.async_block_till_done() - entity = entity_registry.async_get("calendar.name") assert entity.unique_id == "12345" -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +@pytest.mark.parametrize( + "todoist_config", + [{"custom_projects": [{"name": "All projects", "labels": ["Label1"]}]}], +) async def test_update_entity_for_custom_project_with_labels_on( - todoist_api, hass: HomeAssistant, api + hass: HomeAssistant, + api: AsyncMock, ) -> None: """Test that the calendar's state is on for a custom project using labels.""" - todoist_api.return_value = api - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], - } - }, - ) - await hass.async_block_till_done() - await async_update_entity(hass, "calendar.all_projects") state = hass.states.get("calendar.all_projects") assert state.attributes["labels"] == ["Label1"] assert state.state == "on" -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +@pytest.mark.parametrize("due", [None]) async def test_update_entity_for_custom_project_no_due_date_on( - todoist_api, hass: HomeAssistant, api + hass: HomeAssistant, + api: AsyncMock, ) -> None: """Test that a task without an explicit due date is considered to be in an on state.""" - task_wo_due_date = Task( - assignee_id=None, - assigner_id=None, - comment_count=0, - is_completed=False, - content="No due date task", - created_at="2023-04-11T00:25:25.589971Z", - creator_id="1", - description="", - due=None, - id="123", - labels=["Label1"], - order=10, - parent_id=None, - priority=1, - project_id="12345", - section_id=None, - url="https://todoist.com/showTask?id=123", - sync_id=None, - ) - api.get_tasks.return_value = [task_wo_due_date] - todoist_api.return_value = api - - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], - } - }, - ) - await hass.async_block_till_done() - - await async_update_entity(hass, "calendar.all_projects") - state = hass.states.get("calendar.all_projects") + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") assert state.state == "on" -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) -> None: +@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.""" api.get_tasks.side_effect = Exception("API error") - todoist_api.return_value = api assert await setup.async_setup_component( hass, @@ -210,25 +205,14 @@ async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) assert state is None -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +@pytest.mark.parametrize( + "todoist_config", + [{"custom_projects": [{"name": "All projects"}]}], +) async def test_calendar_custom_project_unique_id( - todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique id is None for any custom projects.""" - todoist_api.return_value = api - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - "custom_projects": [{"name": "All projects"}], - } - }, - ) - await hass.async_block_till_done() - entity = entity_registry.async_get("calendar.all_projects") assert entity is None @@ -236,66 +220,66 @@ async def test_calendar_custom_project_unique_id( assert state.state == "off" -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +@pytest.mark.parametrize( + ("due", "start", "end", "expected_response"), + [ + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-28T00:00:00.000Z", + "2023-04-01T00:00:00.000Z", + [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], + ), + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-30T06:00:00.000Z", + "2023-03-31T06:00:00.000Z", + [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], + ), + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-29T08:00:00.000Z", + "2023-03-30T08:00:00.000Z", + [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], + ), + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-30T08:00:00.000Z", + "2023-03-31T08:00:00.000Z", + [get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})], + ), + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-31T08:00:00.000Z", + "2023-04-01T08:00:00.000Z", + [], + ), + ( + Due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-29T06:00:00.000Z", + "2023-03-30T06:00:00.000Z", + [], + ), + ], + ids=("included", "exact", "overlap_start", "overlap_end", "after", "before"), +) async def test_all_day_event( - todoist_api, hass: HomeAssistant, hass_client: ClientSessionGenerator, api + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + start: str, + end: str, + expected_response: dict[str, Any], ) -> None: """Test for an all day calendar event.""" - todoist_api.return_value = api - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], - } - }, - ) - await hass.async_block_till_done() - - await async_update_entity(hass, "calendar.all_projects") client = await hass_client() - start = dt.now() - timedelta(days=1) - end = dt.now() + timedelta(days=1) response = await client.get( - get_events_url("calendar.all_projects", start.isoformat(), end.isoformat()) + get_events_url("calendar.name", start, end), ) assert response.status == HTTPStatus.OK - events = await response.json() - - expected = [ - { - "start": {"date": dt.now().strftime("%Y-%m-%d")}, - "end": {"date": (dt.now() + timedelta(days=1)).strftime("%Y-%m-%d")}, - "summary": "A task", - "description": None, - "location": None, - "uid": None, - "recurrence_id": None, - "rrule": None, - } - ] - assert events == expected + assert await response.json() == expected_response -@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_create_task_service_call(todoist_api, hass: HomeAssistant, api) -> None: +async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> None: """Test api is called correctly after a new task service call.""" - todoist_api.return_value = api - assert await setup.async_setup_component( - hass, - "calendar", - { - "calendar": { - "platform": DOMAIN, - CONF_TOKEN: "token", - } - }, - ) - await hass.async_block_till_done() - await hass.services.async_call( DOMAIN, SERVICE_NEW_TASK, @@ -306,3 +290,100 @@ async def test_create_task_service_call(todoist_api, hass: HomeAssistant, api) - api.add_task.assert_called_with( "task", project_id="12345", labels=["Label1"], assignee_id="1" ) + + +@pytest.mark.parametrize( + ("due"), + [ + # These are all equivalent due dates for the same time in different + # timezone formats. + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Regina", + ), + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 7:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Los_Angeles", + ), + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-30T18:00:00", + ), + ], + ids=("in_local_timezone", "in_other_timezone", "floating"), +) +async def test_task_due_datetime( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test for task due at a specific time, using different time formats.""" + client = await hass_client() + + has_task_response = [ + get_events_response( + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-31T18:00:00-06:00"}, + ) + ] + + # Completely includes the start/end of the task + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == has_task_response + + # Overlap with the start of the event + response = await client.get( + get_events_url( + "calendar.name", "2023-03-29T20:00:00.000Z", "2023-03-31T02:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == has_task_response + + # Overlap with the end of the event + response = await client.get( + get_events_url( + "calendar.name", "2023-03-31T20:00:00.000Z", "2023-04-01T02:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == has_task_response + + # Task is active, but range does not include start/end + response = await client.get( + get_events_url( + "calendar.name", "2023-03-31T10:00:00.000Z", "2023-03-31T11:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == has_task_response + + # Query is before the task starts (no results) + response = await client.get( + get_events_url( + "calendar.name", "2023-03-28T00:00:00.000Z", "2023-03-29T00:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [] + + # Query is after the task ends (no results) + response = await client.get( + get_events_url( + "calendar.name", "2023-04-01T07:00:00.000Z", "2023-04-02T07:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [] diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 649a21f5bcf..aa88766c395 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -34,7 +34,7 @@ async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) - ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -45,7 +45,7 @@ async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" toloclient().get_status_info.side_effect = lambda *args, **kwargs: None @@ -55,7 +55,7 @@ async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: ) assert result2["type"] == FlowResultType.FORM - assert result2["step_id"] == SOURCE_USER + assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py new file mode 100644 index 00000000000..1866273b627 --- /dev/null +++ b/tests/components/tts/common.py @@ -0,0 +1,183 @@ +"""Provide common tests tools for tts.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import media_source +from homeassistant.components.tts import ( + CONF_LANG, + DOMAIN as TTS_DOMAIN, + PLATFORM_SCHEMA, + Provider, + TextToSpeechEntity, + TtsAudioType, + Voice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_integration, + mock_platform, +) + +DEFAULT_LANG = "en_US" +SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] +TEST_DOMAIN = "test" + + +async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: + """Get the media source url.""" + if media_source.DOMAIN not in hass.config.components: + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + resolved = await media_source.async_resolve_media(hass, media_content_id, None) + return resolved.url + + +class BaseProvider: + """Test speech API provider.""" + + def __init__(self, lang: str) -> None: + """Initialize test provider.""" + self._lang = lang + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._lang + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return list of supported languages.""" + if language == "en-US": + return [ + Voice("james_earl_jones", "James Earl Jones"), + Voice("fran_drescher", "Fran Drescher"), + ] + return None + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockProvider(BaseProvider, Provider): + """Test speech API provider.""" + + def __init__(self, lang: str) -> None: + """Initialize test provider.""" + super().__init__(lang) + self.name = "Test" + + +class MockTTSEntity(BaseProvider, TextToSpeechEntity): + """Test speech API provider.""" + + @property + def name(self) -> str: + """Return the name of the entity.""" + return "Test" + + +class MockTTS(MockPlatform): + """A mock TTS platform.""" + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} + ) + + def __init__(self, provider: MockProvider, **kwargs: Any) -> None: + """Initialize.""" + super().__init__(**kwargs) + self._provider = provider + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> Provider | None: + """Set up a mock speech component.""" + return self._provider + + +async def mock_setup( + hass: HomeAssistant, + mock_provider: MockProvider, +) -> None: + """Set up a test provider.""" + mock_integration(hass, MockModule(domain=TEST_DOMAIN)) + mock_platform(hass, f"{TEST_DOMAIN}.{TTS_DOMAIN}", MockTTS(mock_provider)) + + await async_setup_component( + hass, TTS_DOMAIN, {TTS_DOMAIN: {"platform": TEST_DOMAIN}} + ) + await hass.async_block_till_done() + + +async def mock_config_entry_setup( + hass: HomeAssistant, tts_entity: MockTTSEntity +) -> MockConfigEntry: + """Set up a test tts platform via config entry.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, TTS_DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, TTS_DOMAIN) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test tts platform via config entry.""" + async_add_entities([tts_entity]) + + loaded_platform = MockPlatform(async_setup_entry=async_setup_entry_platform) + mock_platform(hass, f"{TEST_DOMAIN}.{TTS_DOMAIN}", loaded_platform) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/tts/conftest.py b/tests/components/tts/conftest.py index c251bdcb8bf..43488808693 100644 --- a/tests/components/tts/conftest.py +++ b/tests/components/tts/conftest.py @@ -2,11 +2,28 @@ From http://doc.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures """ -from unittest.mock import patch +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 + +from .common import ( + DEFAULT_LANG, + TEST_DOMAIN, + MockProvider, + MockTTS, + MockTTSEntity, + mock_config_entry_setup, + mock_setup, +) + +from tests.common import MockModule, mock_config_flow, mock_integration, mock_platform @pytest.hookimpl(tryfirst=True, hookwrapper=True) @@ -31,19 +48,26 @@ def mock_get_cache_files(): @pytest.fixture(autouse=True) -def mock_init_cache_dir(): +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=lambda hass, cache_dir: hass.config.path(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): """Mock the TTS cache dir with empty dir.""" - mock_init_cache_dir.side_effect = None mock_init_cache_dir.return_value = str(tmp_path) # Restore original get cache files behavior, we're working with a real dir. @@ -71,3 +95,60 @@ def mutagen_mock(): side_effect=lambda *args: args[1], ) as mock_write_tags: yield mock_write_tags + + +@pytest.fixture(autouse=True) +async def internal_url_mock(hass: HomeAssistant) -> None: + """Mock internal URL of the instance.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + +@pytest.fixture +async def mock_tts(hass: HomeAssistant, mock_provider) -> None: + """Mock TTS.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(mock_provider)) + + +@pytest.fixture +def mock_provider() -> MockProvider: + """Test TTS provider.""" + return MockProvider(DEFAULT_LANG) + + +@pytest.fixture +def mock_tts_entity() -> MockTTSEntity: + """Test TTS entity.""" + return MockTTSEntity(DEFAULT_LANG) + + +class TTSFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, TTSFlow): + yield + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + request: pytest.FixtureRequest, + mock_provider: MockProvider, + mock_tts_entity: MockTTSEntity, +) -> None: + """Set up the test environment.""" + if request.param == "mock_setup": + await mock_setup(hass, mock_provider) + elif request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, mock_tts_entity) + else: + raise RuntimeError("Invalid setup fixture") diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 251ed9b30c0..cdb8fd9a413 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,11 +1,13 @@ """The tests for the TTS component.""" +import asyncio from http import HTTPStatus from typing import Any +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from homeassistant.components import media_source, tts +from homeassistant.components import tts from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -15,392 +17,623 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.media_source import Unresolvable -from homeassistant.config import async_process_ha_core_config -from homeassistant.core import HomeAssistant +from homeassistant.components.tts.legacy import _valid_base_url +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from homeassistant.util.network import normalize_url -from tests.common import ( - MockModule, - assert_setup_component, - async_mock_service, - mock_integration, - mock_platform, +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockProvider, + MockTTSEntity, + get_media_source_url, + mock_config_entry_setup, + mock_setup, ) -from tests.typing import ClientSessionGenerator + +from tests.common import async_mock_service, mock_restore_cache +from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -async def get_media_source_url(hass, media_content_id): - """Get the media source url.""" - if media_source.DOMAIN not in hass.config.components: - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - resolved = await media_source.async_resolve_media(hass, media_content_id, None) - return resolved.url - - -SUPPORT_LANGUAGES = ["de", "en", "en_US"] - -DEFAULT_LANG = "en" - - -class MockProvider(tts.Provider): - """Test speech API provider.""" - - def __init__(self, lang: str) -> None: - """Initialize test provider.""" - self._lang = lang - self.name = "Test" - - @property - def default_language(self) -> str: - """Return the default language.""" - return self._lang - - @property - def supported_languages(self) -> list[str]: - """Return list of supported languages.""" - return SUPPORT_LANGUAGES - - @property - def supported_options(self) -> list[str]: - """Return list of supported options like voice, emotions.""" - return ["voice", "age"] - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> tts.TtsAudioType: - """Load TTS dat.""" - return ("mp3", b"") - - -class MockTTS: - """A mock TTS platform.""" - - PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA.extend( - {vol.Optional(tts.CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} - ) - - def __init__(self, provider=None) -> None: - """Initialize.""" - if provider is None: - provider = MockProvider - self._provider = provider - - async def async_get_engine( - self, - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: - """Set up a mock speech component.""" - return self._provider(config.get(tts.CONF_LANG, DEFAULT_LANG)) - - @pytest.fixture -def test_provider(): - """Test TTS provider.""" - return MockProvider("en") - - -@pytest.fixture(autouse=True) -async def internal_url_mock(hass): - """Mock internal URL of the instance.""" - await async_process_ha_core_config( - hass, - {"internal_url": "http://example.local:8123"}, - ) - - -@pytest.fixture -async def mock_tts(hass): - """Mock TTS.""" - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS()) - - -@pytest.fixture -async def setup_tts(hass, mock_tts): +async def setup_tts(hass: HomeAssistant, mock_tts: None) -> None: """Mock TTS.""" assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) -async def test_setup_component(hass: HomeAssistant, setup_tts) -> None: +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def default_language(self) -> str: + """Return the default language.""" + return DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.name is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_config_entry_unload( + hass: HomeAssistant, mock_tts_entity: MockTTSEntity +) -> None: + """Test we can unload config entry.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + state = hass.states.get(entity_id) + assert state is None + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + now = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=now): + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: entity_id, + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == now.isoformat() + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + state = hass.states.get(entity_id) + assert state is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +@pytest.mark.parametrize( + "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True +) +async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" - assert hass.services.has_service(tts.DOMAIN, "test_say") assert hass.services.has_service(tts.DOMAIN, "clear_cache") assert f"{tts.DOMAIN}.test" in hass.config.components +@pytest.mark.parametrize("init_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, mock_tts + hass: HomeAssistant, mock_init_cache_dir: MagicMock, setup: str ) -> None: """Set up a TTS platform with defaults.""" - config = {tts.DOMAIN: {"platform": "test"}} - - mock_init_cache_dir.side_effect = OSError(2, "No access") - assert not await async_setup_component(hass, tts.DOMAIN, config) - assert not hass.services.has_service(tts.DOMAIN, "test_say") assert not hass.services.has_service(tts.DOMAIN, "clear_cache") -async def test_setup_component_and_test_service( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_and_test_service_with_config_language( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProvider("de_DE"), MockTTSEntity("de_DE"))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_default_language( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: - """Set up a TTS platform and call service.""" + """Set up a TTS platform with default language and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test", "language": "de"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_-_{expected_url_suffix}.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" + empty_cache_dir + / ( + f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" + ) ).is_file() -async def test_setup_component_and_test_service_with_config_language_special( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProvider("en_US"), MockTTSEntity("en_US"))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_default_special_language( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: - """Set up a TTS platform and call service with extend language.""" + """Set up a TTS platform with default special language and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test", "language": "en_US"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_and_test_service_with_wrong_conf_language( - hass: HomeAssistant, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_language( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: - """Set up a TTS platform and call service with wrong config.""" - config = {tts.DOMAIN: {"platform": "test", "language": "ru"}} - - with assert_setup_component(0, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - -async def test_setup_component_and_test_service_with_service_language( - hass: HomeAssistant, empty_cache_dir, mock_tts -) -> None: - """Set up a TTS platform and call service.""" + """Set up a TTS platform and call service with language.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_-_{expected_url_suffix}.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_test_service_with_wrong_service_language( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "lang", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "lang", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_wrong_language( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "lang", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 0 assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_and_test_service_with_service_options( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"voice": "alex", "age": 5}, + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"voice": "alex", "age": 5}, + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_options( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - tts.ATTR_OPTIONS: {"voice": "alex", "age": 5}, - }, + tts_service, + service_data, blocking=True, ) opt_hash = tts._hash_options({"voice": "alex", "age": 5}) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) ).is_file() -async def test_setup_component_and_test_with_service_options_def( - hass: HomeAssistant, empty_cache_dir +class MockProviderWithDefaults(MockProvider): + """Mock provider with default options.""" + + @property + def default_options(self): + """Return a mapping with the default options.""" + return {"voice": "alex"} + + +class MockEntityWithDefaults(MockTTSEntity): + """Mock entity with default options.""" + + @property + def default_options(self): + """Return a mapping with the default options.""" + return {"voice": "alex"} + + +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProviderWithDefaults(DEFAULT_LANG), MockEntityWithDefaults(DEFAULT_LANG))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_default_options( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service with default options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + opt_hash = tts._hash_options({"voice": "alex"}) - class MockProviderWithDefaults(MockProvider): - @property - def default_options(self): - return {"voice": "alex"} + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + await hass.async_block_till_done() + assert ( + empty_cache_dir + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + ).is_file() - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - await hass.services.async_call( - tts.DOMAIN, +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProviderWithDefaults(DEFAULT_LANG), MockEntityWithDefaults(DEFAULT_LANG))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", "test_say", { - "entity_id": "media_player.something", + ATTR_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"age": 5}, }, - blocking=True, - ) - opt_hash = tts._hash_options({"voice": "alex"}) - - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" - ) - await hass.async_block_till_done() - assert ( - empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" - ).is_file() - - -async def test_setup_component_and_test_with_service_options_def_2( - hass: HomeAssistant, empty_cache_dir + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"age": 5}, + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_merge_default_service_options( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service with default options. @@ -408,66 +641,75 @@ async def test_setup_component_and_test_with_service_options_def_2( """ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + opt_hash = tts._hash_options({"voice": "alex", "age": 5}) - class MockProviderWithDefaults(MockProvider): - @property - def default_options(self): - return {"voice": "alex"} + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + await hass.async_block_till_done() + assert ( + empty_cache_dir + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + ).is_file() - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - await hass.services.async_call( - tts.DOMAIN, +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", "test_say", { - "entity_id": "media_player.something", + ATTR_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - tts.ATTR_OPTIONS: {"age": 5}, + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"speed": 1}, }, - blocking=True, - ) - opt_hash = tts._hash_options({"voice": "alex", "age": 5}) - - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" - ) - await hass.async_block_till_done() - assert ( - empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" - ).is_file() - - -async def test_setup_component_and_test_service_with_service_options_wrong( - hass: HomeAssistant, empty_cache_dir, mock_tts + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de_DE", + tts.ATTR_OPTIONS: {"speed": 1}, + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_wrong_options( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service with wrong options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - tts.ATTR_OPTIONS: {"speed": 1}, - }, + tts_service, + service_data, blocking=True, ) opt_hash = tts._hash_options({"speed": 1}) @@ -476,58 +718,53 @@ async def test_setup_component_and_test_service_with_service_options_wrong( await hass.async_block_till_done() assert not ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) ).is_file() -async def test_setup_component_and_test_service_with_base_url_set( - hass: HomeAssistant, mock_tts -) -> None: - """Set up a TTS platform with ``base_url`` set and call service.""" - calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {tts.DOMAIN: {"platform": "test", "base_url": "http://fnord"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - await hass.services.async_call( - tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "http://fnord" - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en_-_test.mp3" - ) - - -async def test_setup_component_and_test_service_clear_cache( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_clear_cache( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service clear cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, + tts_service, + service_data, blocking=True, ) # To make sure the file is persisted @@ -535,7 +772,8 @@ async def test_setup_component_and_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 / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() await hass.services.async_call( @@ -543,43 +781,66 @@ async def test_setup_component_and_test_service_clear_cache( ) assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_and_test_service_with_receive_voice( - hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_receive_voice( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - message = "There is someone at the door." - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: message, - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - _, tts_data = test_provider.get_tts_audio("bla", "en") + tts_data = b"" tts_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3", tts_data, - test_provider, - message, + "Test", + service_data[tts.ATTR_MESSAGE], "en", None, ) @@ -593,35 +854,63 @@ async def test_setup_component_and_test_service_with_receive_voice( assert tts_data == data -async def test_setup_component_and_test_service_with_receive_voice_german( - hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProvider("de_DE"), MockTTSEntity("de_DE"))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_receive_voice_german( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test", "language": "de"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, + tts_service, + service_data, blocking=True, ) assert len(calls) == 1 url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - _, tts_data = test_provider.get_tts_audio("bla", "de") + tts_data = b"" tts_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3", + "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3", tts_data, - test_provider, + "Test", "There is someone at the door.", "de", None, @@ -630,183 +919,274 @@ async def test_setup_component_and_test_service_with_receive_voice_german( assert await req.read() == tts_data -async def test_setup_component_and_web_view_wrong_file( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_file( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, ) -> None: """Set up a TTS platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + url = ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ) req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND -async def test_setup_component_and_web_view_wrong_filename( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_filename( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, ) -> None: """Set up a TTS platform and receive wrong filename from web.""" - config = {tts.DOMAIN: {"platform": "test"}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - client = await hass_client() - url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_test.mp3" + url = ( + "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd" + f"_en-us_-_{expected_url_suffix}.mp3" + ) req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND -async def test_setup_component_test_without_cache( - hass: HomeAssistant, empty_cache_dir, mock_tts -) -> None: - """Set up a TTS platform without cache.""" - calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {tts.DOMAIN: {"platform": "test", "cache": False}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - - await hass.services.async_call( - tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 - await hass.async_block_till_done() - assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" - ).is_file() - - -async def test_setup_component_test_with_cache_call_service_without_cache( - hass: HomeAssistant, empty_cache_dir, mock_tts +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data", "expected_url_suffix"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_CACHE: False, + }, + "test", + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_CACHE: False, + }, + "tts.test", + ), + ], + indirect=["setup"], +) +async def test_service_without_cache( + hass: HomeAssistant, + empty_cache_dir, + setup: str, + tts_service: str, + service_data: dict[str, Any], + expected_url_suffix: str, ) -> None: """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "test", "cache": True}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) - await hass.services.async_call( tts.DOMAIN, - "test_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_CACHE: False, - }, + tts_service, + service_data, blocking=True, ) - assert len(calls) == 1 await hass.async_block_till_done() + assert len(calls) == 1 assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() -async def test_setup_component_test_with_cache_dir( - hass: HomeAssistant, empty_cache_dir, test_provider +class MockProviderBoom(MockProvider): + """Mock provider that blows up.""" + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + # This should not be called, data should be fetched from cache + raise Exception("Boom!") # pylint: disable=broad-exception-raised + + +class MockEntityBoom(MockTTSEntity): + """Mock entity that blows up.""" + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + # This should not be called, data should be fetched from cache + raise Exception("Boom!") # pylint: disable=broad-exception-raised + + +@pytest.mark.parametrize("mock_provider", [MockProviderBoom(DEFAULT_LANG)]) +async def test_setup_legacy_cache_dir( + hass: HomeAssistant, + empty_cache_dir, + mock_provider: MockProvider, ) -> 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 = test_provider.get_tts_audio("bla", "en") + tts_data = b"" cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) with open(cache_file, "wb") as voice_file: voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "test", "cache": True}} - - class MockProviderBoom(MockProvider): - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> tts.TtsAudioType: - """Load TTS dat.""" - # This should not be called, data should be fetched from cache - raise Exception("Boom!") - - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS(MockProviderBoom)) - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) + await mock_setup(hass, mock_provider) await hass.services.async_call( tts.DOMAIN, "test_say", { - "entity_id": "media_player.something", + ATTR_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) + assert len(calls) == 1 - assert ( - await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) + await hass.async_block_till_done() -async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: - """Set up a TTS platform with wrong get_tts_audio.""" +@pytest.mark.parametrize("mock_tts_entity", [MockEntityBoom(DEFAULT_LANG)]) +async def test_setup_cache_dir( + hass: HomeAssistant, + empty_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) - config = {tts.DOMAIN: {"platform": "test"}} + tts_data = b"" + cache_file = empty_cache_dir / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" + ) - class MockProviderEmpty(MockProvider): - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> tts.TtsAudioType: - """Load TTS dat.""" - return (None, None) + with open(cache_file, "wb") as voice_file: + voice_file.write(tts_data) - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS(MockProviderEmpty)) - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) + await mock_config_entry_setup(hass, mock_tts_entity) await hass.services.async_call( tts.DOMAIN, - "test_say", + "speak", { - "entity_id": "media_player.something", + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) + + assert len(calls) == 1 + assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" + ) + await hass.async_block_till_done() + + +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 + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return (None, None) + + +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 + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return (None, None) + + +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MockProviderEmpty(DEFAULT_LANG), MockEntityEmpty(DEFAULT_LANG))], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_setup", + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.test", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + ), + ], + indirect=["setup"], +) +async def test_service_get_tts_error( + hass: HomeAssistant, + setup: str, + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Set up a TTS platform with wrong get_tts_audio.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) assert len(calls) == 1 with pytest.raises(Unresolvable): await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) -async def test_setup_component_load_cache_retrieve_without_mem_cache( +async def test_load_cache_legacy_retrieve_without_mem_cache( hass: HomeAssistant, - test_provider, + mock_provider: MockProvider, empty_cache_dir, hass_client: ClientSessionGenerator, - mock_tts, ) -> None: """Set up component and load cache and get without mem cache.""" - _, tts_data = test_provider.get_tts_audio("bla", "en") + tts_data = b"" cache_file = ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) @@ -814,10 +1194,7 @@ async def test_setup_component_load_cache_retrieve_without_mem_cache( with open(cache_file, "wb") as voice_file: voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "test", "cache": True}} - - with assert_setup_component(1, tts.DOMAIN): - assert await async_setup_component(hass, tts.DOMAIN, config) + await mock_setup(hass, mock_provider) client = await hass_client() @@ -828,46 +1205,95 @@ async def test_setup_component_load_cache_retrieve_without_mem_cache( assert await req.read() == tts_data -async def test_setup_component_and_web_get_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts +async def test_load_cache_retrieve_without_mem_cache( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, + empty_cache_dir, + hass_client: ClientSessionGenerator, ) -> None: - """Set up a TTS platform and receive file from web.""" - config = {tts.DOMAIN: {"platform": "test"}} + """Set up component and load cache and get without mem cache.""" + tts_data = b"" + cache_file = empty_cache_dir / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" + ) - await async_setup_component(hass, tts.DOMAIN, config) + with open(cache_file, "wb") as voice_file: + voice_file.write(tts_data) + + await mock_config_entry_setup(hass, mock_tts_entity) client = await hass_client() + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" + + req = await client.get(url) + assert req.status == HTTPStatus.OK + assert await req.read() == tts_data + + +@pytest.mark.parametrize( + ("setup", "data", "expected_url_suffix"), + [ + ("mock_setup", {"platform": "test"}, "test"), + ("mock_setup", {"engine_id": "test"}, "test"), + ("mock_config_entry_setup", {"engine_id": "tts.test"}, "tts.test"), + ], + indirect=["setup"], +) +async def test_web_get_url( + hass_client: ClientSessionGenerator, + setup: str, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Set up a TTS platform and receive file from web.""" + client = await hass_client() + url = "/api/tts_get_url" - data = {"platform": "test", "message": "There is someone at the door."} + data |= {"message": "There is someone at the door."} req = await client.post(url, json=data) assert req.status == HTTPStatus.OK response = await req.json() assert response == { - "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", - "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ), } -async def test_setup_component_and_web_get_url_bad_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts +@pytest.mark.parametrize( + ("setup", "data"), + [ + ("mock_setup", {"platform": "test"}), + ("mock_setup", {"engine_id": "test"}), + ("mock_setup", {"message": "There is someone at the door."}), + ("mock_config_entry_setup", {"engine_id": "tts.test"}), + ("mock_config_entry_setup", {"message": "There is someone at the door."}), + ], + indirect=["setup"], +) +async def test_web_get_url_missing_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + data: dict[str, Any], ) -> None: """Set up a TTS platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "test"}} - - await async_setup_component(hass, tts.DOMAIN, config) - client = await hass_client() - url = "/api/tts_get_url" - data = {"message": "There is someone at the door."} req = await client.post(url, json=data) assert req.status == HTTPStatus.BAD_REQUEST -async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: +async def test_tags_with_wave() -> None: """Set up a TTS platform and call service and receive voice.""" # below data represents an empty wav file @@ -879,7 +1305,7 @@ async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: tagged_data = ORIG_WRITE_TAGS( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.wav", tts_data, - test_provider, + "Test", "AI person is in front of your door.", "en", None, @@ -901,9 +1327,9 @@ async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: ) def test_valid_base_url(value) -> None: """Test we validate base urls.""" - assert tts.valid_base_url(value) == normalize_url(value) + assert _valid_base_url(value) == normalize_url(value) # Test we strip trailing `/` - assert tts.valid_base_url(value + "/") == normalize_url(value) + assert _valid_base_url(value + "/") == normalize_url(value) @pytest.mark.parametrize( @@ -923,27 +1349,35 @@ def test_valid_base_url(value) -> None: def test_invalid_base_url(value) -> None: """Test we catch bad base urls.""" with pytest.raises(vol.Invalid): - tts.valid_base_url(value) + _valid_base_url(value) @pytest.mark.parametrize( - ("engine", "language", "options", "cache", "result_engine", "result_query"), + ("setup", "result_engine"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +@pytest.mark.parametrize( + ("engine", "language", "options", "cache", "result_query"), ( - (None, None, None, None, "test", ""), - (None, "de", None, None, "test", "language=de"), - (None, "de", {"voice": "henk"}, None, "test", "language=de&voice=henk"), - (None, "de", None, True, "test", "cache=true&language=de"), + (None, None, None, None, ""), + (None, "de_DE", None, None, "language=de_DE"), + (None, "de_DE", {"voice": "henk"}, None, "language=de_DE&voice=henk"), + (None, "de_DE", None, True, "cache=true&language=de_DE"), ), ) async def test_generate_media_source_id( hass: HomeAssistant, - setup_tts, - engine, - language, - options, - cache, - result_engine, - result_query, + setup: str, + result_engine: str, + engine: str | None, + language: str | None, + options: dict[str, Any] | None, + cache: bool | None, + result_query: str, ) -> None: """Test generating a media source ID.""" media_source_id = tts.generate_media_source_id( @@ -958,6 +1392,14 @@ async def test_generate_media_source_id( assert query[12:] == result_query +@pytest.mark.parametrize( + "setup", + [ + "mock_setup", + "mock_config_entry_setup", + ], + indirect=["setup"], +) @pytest.mark.parametrize( ("engine", "language", "options"), ( @@ -967,8 +1409,341 @@ async def test_generate_media_source_id( ), ) async def test_generate_media_source_id_invalid_options( - hass: HomeAssistant, setup_tts, engine, language, options + hass: HomeAssistant, + setup: str, + engine: str | None, + language: str | None, + options: dict[str, Any] | None, ) -> None: """Test generating a media source ID.""" with pytest.raises(HomeAssistantError): tts.generate_media_source_id(hass, "msg", engine, language, options, None) + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None: + """Test resolving engine.""" + assert tts.async_resolve_engine(hass, None) == engine_id + assert tts.async_resolve_engine(hass, engine_id) == engine_id + assert tts.async_resolve_engine(hass, "non-existing") is None + + with patch.dict( + hass.data[tts.DATA_TTS_MANAGER].providers, {}, clear=True + ), patch.dict(hass.data[tts.DOMAIN]._platforms, {}, clear=True): + assert tts.async_resolve_engine(hass, None) is None + + with patch.dict(hass.data[tts.DATA_TTS_MANAGER].providers, {"cloud": object()}): + assert tts.async_resolve_engine(hass, None) == "cloud" + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: + """Test supporting options.""" + assert await tts.async_support_options(hass, engine_id, "en_US") is True + assert await tts.async_support_options(hass, engine_id, "nl") is False + assert ( + await tts.async_support_options( + hass, engine_id, "en_US", {"invalid_option": "yo"} + ) + is False + ) + + with pytest.raises(HomeAssistantError): + await tts.async_support_options(hass, "non-existing") + + +async def test_legacy_fetching_in_async( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test async fetching of data for a legacy provider.""" + tts_audio: asyncio.Future[bytes] = asyncio.Future() + + class ProviderWithAsyncFetching(MockProvider): + """Provider that supports audio output option.""" + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return [tts.ATTR_AUDIO_OUTPUT] + + @property + def default_options(self) -> dict[str, str]: + """Return a dict including the default options.""" + return {tts.ATTR_AUDIO_OUTPUT: "mp3"} + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + return ("mp3", await tts_audio) + + await mock_setup(hass, ProviderWithAsyncFetching(DEFAULT_LANG)) + + # Test async_get_media_source_audio + media_source_id = tts.generate_media_source_id( + hass, "test message", "test", "en_US", None, None + ) + + task = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + task2 = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + + url = await get_media_source_url(hass, media_source_id) + client = await hass_client() + client_get_task = hass.async_create_task(client.get(url)) + + # Make sure that tasks are waiting for our future to resolve + done, pending = await asyncio.wait((task, task2, client_get_task), timeout=0.1) + assert len(done) == 0 + assert len(pending) == 3 + + tts_audio.set_result(b"test") + + assert await task == ("mp3", b"test") + assert await task2 == ("mp3", b"test") + + req = await client_get_task + assert req.status == HTTPStatus.OK + assert await req.read() == b"test" + + # Test error is not cached + media_source_id = tts.generate_media_source_id( + hass, "test message 2", "test", "en_US", None, None + ) + tts_audio = asyncio.Future() + tts_audio.set_exception(HomeAssistantError("test error")) + with pytest.raises(HomeAssistantError): + assert await tts.async_get_media_source_audio(hass, media_source_id) + + tts_audio = asyncio.Future() + tts_audio.set_result(b"test 2") + assert await tts.async_get_media_source_audio(hass, media_source_id) == ( + "mp3", + b"test 2", + ) + + +async def test_fetching_in_async( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test async fetching of data.""" + tts_audio: asyncio.Future[bytes] = asyncio.Future() + + class EntityWithAsyncFetching(MockTTSEntity): + """Entity that supports audio output option.""" + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return [tts.ATTR_AUDIO_OUTPUT] + + @property + def default_options(self) -> dict[str, str]: + """Return a dict including the default options.""" + return {tts.ATTR_AUDIO_OUTPUT: "mp3"} + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + return ("mp3", await tts_audio) + + await mock_config_entry_setup(hass, EntityWithAsyncFetching(DEFAULT_LANG)) + + # Test async_get_media_source_audio + media_source_id = tts.generate_media_source_id( + hass, "test message", "tts.test", "en_US", None, None + ) + + task = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + task2 = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + + url = await get_media_source_url(hass, media_source_id) + client = await hass_client() + client_get_task = hass.async_create_task(client.get(url)) + + # Make sure that tasks are waiting for our future to resolve + done, pending = await asyncio.wait((task, task2, client_get_task), timeout=0.1) + assert len(done) == 0 + assert len(pending) == 3 + + tts_audio.set_result(b"test") + + assert await task == ("mp3", b"test") + assert await task2 == ("mp3", b"test") + + req = await client_get_task + assert req.status == HTTPStatus.OK + assert await req.read() == b"test" + + # Test error is not cached + media_source_id = tts.generate_media_source_id( + hass, "test message 2", "tts.test", "en_US", None, None + ) + tts_audio = asyncio.Future() + tts_audio.set_exception(HomeAssistantError("test error")) + with pytest.raises(HomeAssistantError): + assert await tts.async_get_media_source_audio(hass, media_source_id) + + tts_audio = asyncio.Future() + tts_audio.set_result(b"test 2") + assert await tts.async_get_media_source_audio(hass, media_source_id) == ( + "mp3", + b"test 2", + ) + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_ws_list_engines( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test streaming audio and getting response.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + { + "engine_id": engine_id, + "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], + } + ] + } + + await client.send_json_auto_id({"type": "tts/engine/list", "language": "smurfish"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [{"engine_id": engine_id, "supported_languages": []}] + } + + await client.send_json_auto_id({"type": "tts/engine/list", "language": "en"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + {"engine_id": engine_id, "supported_languages": ["en_US", "en_GB"]} + ] + } + + await client.send_json_auto_id({"type": "tts/engine/list", "language": "en-UK"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + {"engine_id": engine_id, "supported_languages": ["en_GB", "en_US"]} + ] + } + + await client.send_json_auto_id({"type": "tts/engine/list", "language": "de"}) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == { + "providers": [ + {"engine_id": engine_id, "supported_languages": ["de_DE", "de_CH"]} + ] + } + + await client.send_json_auto_id( + {"type": "tts/engine/list", "language": "de", "country": "ch"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == { + "providers": [ + {"engine_id": engine_id, "supported_languages": ["de_CH", "de_DE"]} + ] + } + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_ws_list_voices( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test streaming audio and getting response.""" + client = await hass_ws_client() + + await client.send_json_auto_id( + { + "type": "tts/engine/voices", + "engine_id": "smurf_tts", + "language": "smurfish", + } + ) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "tts engine smurf_tts not found", + } + + await client.send_json_auto_id( + { + "type": "tts/engine/voices", + "engine_id": engine_id, + "language": "smurfish", + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"voices": None} + + await client.send_json_auto_id( + { + "type": "tts/engine/voices", + "engine_id": engine_id, + "language": "en-US", + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "voices": [ + {"voice_id": "james_earl_jones", "name": "James Earl Jones"}, + {"voice_id": "fran_drescher", "name": "Fran Drescher"}, + ] + } diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py new file mode 100644 index 00000000000..0880fcf125a --- /dev/null +++ b/tests/components/tts/test_legacy.py @@ -0,0 +1,195 @@ +"""Test the legacy tts setup.""" +from __future__ import annotations + +import pytest + +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.components.tts import ATTR_MESSAGE, DOMAIN, Provider +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_setup_component + +from .common import SUPPORT_LANGUAGES, MockProvider, MockTTS, get_media_source_url + +from tests.common import ( + MockModule, + assert_setup_component, + async_mock_service, + mock_integration, + mock_platform, +) + + +class DefaultProvider(Provider): + """Test provider.""" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + +async def test_default_provider_attributes() -> None: + """Test default provider attributes.""" + provider = DefaultProvider() + + assert provider.hass is None + assert provider.name is None + assert provider.default_language is None + assert provider.supported_languages == SUPPORT_LANGUAGES + assert provider.supported_options is None + assert provider.default_options is None + assert provider.async_get_supported_voices("test") is None + + +async def test_deprecated_platform(hass: HomeAssistant) -> None: + """Test deprecated google platform.""" + with assert_setup_component(0, DOMAIN): + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {"platform": "google"}} + ) + + +async def test_invalid_platform( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test platform setup with an invalid platform.""" + await async_load_platform( + hass, + "tts", + "bad_tts", + {"tts": [{"platform": "bad_tts"}]}, + hass_config={"tts": [{"platform": "bad_tts"}]}, + ) + await hass.async_block_till_done() + + assert "Unknown text to speech platform specified" in caplog.text + + +async def test_platform_setup_without_provider( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_provider: MockProvider +) -> None: + """Test platform setup without provider returned.""" + + class BadPlatform(MockTTS): + """A mock TTS platform without provider.""" + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> Provider | None: + """Raise exception during platform setup.""" + return None + + mock_integration(hass, MockModule(domain="bad_tts")) + mock_platform(hass, "bad_tts.tts", BadPlatform(mock_provider)) + + await async_load_platform( + hass, + "tts", + "bad_tts", + {}, + hass_config={"tts": [{"platform": "bad_tts"}]}, + ) + await hass.async_block_till_done() + + assert "Error setting up platform: bad_tts" in caplog.text + + +async def test_platform_setup_with_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_provider: MockProvider, +) -> None: + """Test platform setup with an error during setup.""" + + class BadPlatform(MockTTS): + """A mock TTS platform with a setup error.""" + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> Provider: + """Raise exception during platform setup.""" + raise Exception("Setup error") # pylint: disable=broad-exception-raised + + mock_integration(hass, MockModule(domain="bad_tts")) + mock_platform(hass, "bad_tts.tts", BadPlatform(mock_provider)) + + await async_load_platform( + hass, + "tts", + "bad_tts", + {}, + hass_config={"tts": [{"platform": "bad_tts"}]}, + ) + await hass.async_block_till_done() + + assert "Error setting up platform: bad_tts" in caplog.text + + +async def test_service_base_url_set(hass: HomeAssistant, mock_tts) -> None: + """Set up a TTS platform with ``base_url`` set and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {DOMAIN: {"platform": "test", "base_url": "http://fnord"}} + + with assert_setup_component(1, DOMAIN): + assert await async_setup_component(hass, DOMAIN, config) + + await hass.services.async_call( + DOMAIN, + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert ( + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == "http://fnord" + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + "_en-us_-_test.mp3" + ) + + +async def test_service_without_cache_config( + hass: HomeAssistant, empty_cache_dir, mock_tts +) -> None: + """Set up a TTS platform without cache.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {DOMAIN: {"platform": "test", "cache": False}} + + with assert_setup_component(1, DOMAIN): + assert await async_setup_component(hass, DOMAIN, config) + + await hass.services.async_call( + DOMAIN, + "test_say", + { + ATTR_ENTITY_ID: "media_player.something", + ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + await hass.async_block_till_done() + assert not ( + empty_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 8444fdb963c..ef2cbb651e8 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -1,5 +1,5 @@ """Tests for TTS media source.""" -from unittest.mock import patch +from unittest.mock import MagicMock import pytest @@ -8,33 +8,52 @@ from homeassistant.components.media_player.errors import BrowseError from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import ( + DEFAULT_LANG, + MockProvider, + MockTTSEntity, + mock_config_entry_setup, + mock_setup, +) + + +class MSEntity(MockTTSEntity): + """Test speech API entity.""" + + get_tts_audio = MagicMock(return_value=("mp3", b"")) + + +class MSProvider(MockProvider): + """Test speech API provider.""" + + get_tts_audio = MagicMock(return_value=("mp3", b"")) + @pytest.fixture(autouse=True) -async def mock_get_tts_audio(hass): +async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts -async def test_browsing(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MSProvider(DEFAULT_LANG), MSEntity(DEFAULT_LANG))], +) +@pytest.mark.parametrize( + "setup", + [ + "mock_setup", + "mock_config_entry_setup", + ], + indirect=["setup"], +) +async def test_browsing(hass: HomeAssistant, setup: str) -> None: """Test browsing TTS media source.""" item = await media_source.async_browse_media(hass, "media-source://tts") + assert item is not None assert item.title == "Text to Speech" + assert item.children is not None assert len(item.children) == 1 assert item.can_play is False assert item.can_expand is True @@ -42,9 +61,10 @@ async def test_browsing(hass: HomeAssistant) -> None: item_child = await media_source.async_browse_media( hass, item.children[0].media_content_id ) + assert item_child is not None assert item_child.media_content_id == item.children[0].media_content_id - assert item_child.title == "Demo" + assert item_child.title == "Test" assert item_child.children is None assert item_child.can_play is False assert item_child.can_expand is True @@ -52,12 +72,13 @@ async def test_browsing(hass: HomeAssistant) -> None: item_child = await media_source.async_browse_media( hass, item.children[0].media_content_id + "?message=bla" ) + assert item_child is not None assert ( item_child.media_content_id == item.children[0].media_content_id + "?message=bla" ) - assert item_child.title == "Demo" + assert item_child.title == "Test" assert item_child.children is None assert item_child.can_play is False assert item_child.can_expand is True @@ -66,10 +87,14 @@ async def test_browsing(hass: HomeAssistant) -> None: await media_source.async_browse_media(hass, "media-source://tts/non-existing") -async def test_resolving(hass: HomeAssistant, mock_get_tts_audio) -> None: - """Test resolving.""" +@pytest.mark.parametrize("mock_provider", [MSProvider(DEFAULT_LANG)]) +async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) -> None: + """Test resolving legacy provider.""" + await mock_setup(hass, mock_provider) + mock_get_tts_audio = mock_provider.get_tts_audio + media = await media_source.async_resolve_media( - hass, "media-source://tts/demo?message=Hello%20World", None + hass, "media-source://tts/test?message=Hello%20World", None ) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -77,14 +102,14 @@ async def test_resolving(hass: HomeAssistant, mock_get_tts_audio) -> None: assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Hello World" - assert language == "en" + assert language == "en_US" assert mock_get_tts_audio.mock_calls[0][2]["options"] is None # Pass language and options mock_get_tts_audio.reset_mock() media = await media_source.async_resolve_media( hass, - "media-source://tts/demo?message=Bye%20World&language=de&voice=Paulus", + "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus", None, ) assert media.url.startswith("/api/tts_proxy/") @@ -93,15 +118,62 @@ async def test_resolving(hass: HomeAssistant, mock_get_tts_audio) -> None: assert len(mock_get_tts_audio.mock_calls) == 1 message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Bye World" - assert language == "de" + assert language == "de_DE" assert mock_get_tts_audio.mock_calls[0][2]["options"] == {"voice": "Paulus"} -async def test_resolving_errors(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("mock_tts_entity", [MSEntity(DEFAULT_LANG)]) +async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None: + """Test resolving entity.""" + await mock_config_entry_setup(hass, mock_tts_entity) + mock_get_tts_audio = mock_tts_entity.get_tts_audio + + media = await media_source.async_resolve_media( + hass, "media-source://tts/tts.test?message=Hello%20World", None + ) + assert media.url.startswith("/api/tts_proxy/") + assert media.mime_type == "audio/mpeg" + + assert len(mock_get_tts_audio.mock_calls) == 1 + 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 + + # Pass language and options + mock_get_tts_audio.reset_mock() + media = await media_source.async_resolve_media( + hass, + "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus", + None, + ) + assert media.url.startswith("/api/tts_proxy/") + assert media.mime_type == "audio/mpeg" + + assert len(mock_get_tts_audio.mock_calls) == 1 + message, language = mock_get_tts_audio.mock_calls[0][1] + assert message == "Bye World" + assert language == "de_DE" + assert mock_get_tts_audio.mock_calls[0][2]["options"] == {"voice": "Paulus"} + + +@pytest.mark.parametrize( + ("mock_provider", "mock_tts_entity"), + [(MSProvider(DEFAULT_LANG), MSEntity(DEFAULT_LANG))], +) +@pytest.mark.parametrize( + "setup", + [ + "mock_setup", + "mock_config_entry_setup", + ], + indirect=["setup"], +) +async def test_resolving_errors(hass: HomeAssistant, setup: str) -> None: """Test resolving.""" # No message added with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "media-source://tts/demo", None) + await media_source.async_resolve_media(hass, "media-source://tts/test", None) # Non-existing provider with pytest.raises(media_source.Unresolvable): diff --git a/tests/components/tts/test_notify.py b/tests/components/tts/test_notify.py index 54ccc1824ed..be528459a70 100644 --- a/tests/components/tts/test_notify.py +++ b/tests/components/tts/test_notify.py @@ -1,28 +1,22 @@ """The tests for the TTS component.""" import pytest -import yarl -import homeassistant.components.media_player as media_player +from homeassistant.components import media_player, notify, tts from homeassistant.components.media_player import ( DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -import homeassistant.components.notify as notify -import homeassistant.components.tts as tts from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import MockTTSEntity, mock_config_entry_setup + from tests.common import assert_setup_component, async_mock_service -def relative_url(url): - """Convert an absolute url to a relative one.""" - return str(yarl.URL(url).relative()) - - @pytest.fixture(autouse=True) -async def internal_url_mock(hass): +async def internal_url_mock(hass: HomeAssistant) -> None: """Mock internal URL of the instance.""" await async_process_ha_core_config( hass, @@ -30,8 +24,8 @@ async def internal_url_mock(hass): ) -async def test_setup_platform(hass: HomeAssistant) -> None: - """Set up the tts platform .""" +async def test_setup_legacy_platform(hass: HomeAssistant) -> None: + """Set up the tts notify platform .""" config = { notify.DOMAIN: { "platform": "tts", @@ -46,7 +40,23 @@ async def test_setup_platform(hass: HomeAssistant) -> None: assert hass.services.has_service(notify.DOMAIN, "tts_test") -async def test_setup_component_and_test_service(hass: HomeAssistant) -> None: +async def test_setup_platform(hass: HomeAssistant) -> None: + """Set up the tts notify platform .""" + config = { + notify.DOMAIN: { + "platform": "tts", + "name": "tts_test", + "entity_id": "tts.test", + "media_player": "media_player.demo", + } + } + with assert_setup_component(1, notify.DOMAIN): + assert await async_setup_component(hass, notify.DOMAIN, config) + + assert hass.services.has_service(notify.DOMAIN, "tts_test") + + +async def test_setup_legacy_service(hass: HomeAssistant) -> None: """Set up the demo platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -62,6 +72,8 @@ async def test_setup_component_and_test_service(hass: HomeAssistant) -> None: }, } + await async_setup_component(hass, "homeassistant", {}) + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -80,3 +92,38 @@ async def test_setup_component_and_test_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 1 + + +async def test_setup_service( + hass: HomeAssistant, mock_tts_entity: MockTTSEntity +) -> None: + """Set up platform and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + notify.DOMAIN: { + "platform": "tts", + "name": "tts_test", + "entity_id": "tts.test", + "media_player": "media_player.demo", + "language": "en_US", + }, + } + + await mock_config_entry_setup(hass, mock_tts_entity) + + with assert_setup_component(1, notify.DOMAIN): + assert await async_setup_component(hass, notify.DOMAIN, config) + + await hass.services.async_call( + notify.DOMAIN, + "tts_test", + { + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + + await hass.async_block_till_done() + + assert len(calls) == 1 diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index d8c2c82f4eb..e5875ecaab7 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -30,7 +30,7 @@ async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -60,7 +60,7 @@ async def test_invalid_address( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_twentemilieu.unique_id.side_effect = TwenteMilieuAddressError result2 = await hass.config_entries.flow.async_configure( @@ -72,7 +72,7 @@ async def test_invalid_address( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"base": "invalid_address"} mock_twentemilieu.unique_id.side_effect = None @@ -106,7 +106,7 @@ async def test_connection_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" assert result.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 0d358ef5149..d91771322f3 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -21,7 +21,7 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_WIRED_CLIENTS, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -398,7 +398,7 @@ async def test_reauth_flow_update_configuration( ) assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" aioclient_mock.clear_requests() diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 1e68b497111..16432ff514e 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -144,6 +144,9 @@ async def test_tracked_clients( assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 4 assert hass.states.get("device_tracker.client_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.client_2").state == STATE_NOT_HOME + assert ( + hass.states.get("device_tracker.client_5").attributes["host_name"] == "client_5" + ) # Client on SSID not in SSID filter assert not hass.states.get("device_tracker.client_3") diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 6ea52c95a9f..6b7ee2cd0a7 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -138,6 +138,7 @@ DEVICE_1 = { "media": "GE", "name": "Port 1", "port_idx": 1, + "poe_caps": 7, "poe_class": "Class 4", "poe_enable": True, "poe_mode": "auto", @@ -151,6 +152,7 @@ DEVICE_1 = { "media": "GE", "name": "Port 2", "port_idx": 2, + "poe_caps": 7, "poe_class": "Class 4", "poe_enable": True, "poe_mode": "auto", @@ -164,6 +166,7 @@ DEVICE_1 = { "media": "GE", "name": "Port 3", "port_idx": 3, + "poe_caps": 7, "poe_class": "Unknown", "poe_enable": False, "poe_mode": "off", @@ -177,6 +180,7 @@ DEVICE_1 = { "media": "GE", "name": "Port 4", "port_idx": 4, + "poe_caps": 7, "poe_class": "Unknown", "poe_enable": False, "poe_mode": "auto", diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index c8fc62296a7..ab6e3fcb5ae 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -69,7 +69,9 @@ async def test_exclude_attributes( assert state.attributes[ATTR_EVENT_SCORE] == 100 await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 01505f6ffc8..6987e526e34 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -3,10 +3,8 @@ from __future__ import annotations from copy import copy -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import ( Camera, DoorbellMessageType, @@ -22,21 +20,15 @@ from pyunifiprotect.data import ( from pyunifiprotect.data.nvr import DoorbellMessage from homeassistant.components.select import ATTR_OPTIONS -from homeassistant.components.unifiprotect.const import ( - ATTR_DURATION, - ATTR_MESSAGE, - DEFAULT_ATTRIBUTION, -) +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.select import ( CAMERA_SELECTS, LIGHT_MODE_OFF, LIGHT_SELECTS, - SERVICE_SET_DOORBELL_MESSAGE, VIEWER_SELECTS, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .utils import ( @@ -547,99 +539,3 @@ async def test_select_set_option_viewer( ) viewer.set_liveview.assert_called_once_with(liveview) - - -async def test_select_service_doorbell_invalid( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera -) -> None: - """Test Doorbell Text service (invalid).""" - - await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SELECT, 4, 4) - - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[1] - ) - - doorbell.__fields__["set_lcd_text"] = Mock(final=False) - doorbell.set_lcd_text = AsyncMock() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "unifiprotect", - SERVICE_SET_DOORBELL_MESSAGE, - {ATTR_ENTITY_ID: entity_id, ATTR_MESSAGE: "Test"}, - blocking=True, - ) - - assert not doorbell.set_lcd_text.called - - -async def test_select_service_doorbell_success( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera -) -> None: - """Test Doorbell Text service (success).""" - - await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SELECT, 4, 4) - - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] - ) - - doorbell.__fields__["set_lcd_text"] = Mock(final=False) - doorbell.set_lcd_text = AsyncMock() - - await hass.services.async_call( - "unifiprotect", - SERVICE_SET_DOORBELL_MESSAGE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MESSAGE: "Test", - }, - blocking=True, - ) - - doorbell.set_lcd_text.assert_called_once_with( - DoorbellMessageType.CUSTOM_MESSAGE, "Test", reset_at=None - ) - - -@patch("homeassistant.components.unifiprotect.select.utcnow") -async def test_select_service_doorbell_with_reset( - mock_now, - hass: HomeAssistant, - ufp: MockUFPFixture, - doorbell: Camera, - fixed_now: datetime, -) -> None: - """Test Doorbell Text service (success with reset time).""" - - mock_now.return_value = fixed_now - - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] - ) - - await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SELECT, 4, 4) - - doorbell.__fields__["set_lcd_text"] = Mock(final=False) - doorbell.set_lcd_text = AsyncMock() - - await hass.services.async_call( - "unifiprotect", - SERVICE_SET_DOORBELL_MESSAGE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MESSAGE: "Test", - ATTR_DURATION: 60, - }, - blocking=True, - ) - - doorbell.set_lcd_text.assert_called_once_with( - DoorbellMessageType.CUSTOM_MESSAGE, - "Test", - reset_at=fixed_now + timedelta(minutes=60), - ) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2d6ab9937a3..2a0a0eb0655 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -150,7 +150,6 @@ def add_device( if regenerate_ids: regenerate_device_ids(device) - device._initial_data = device.dict() devices = getattr(bootstrap, f"{device.model.value}s") devices[device.id] = device diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index fbf4e576dd5..a6cf342eeb3 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1105,6 +1105,7 @@ async def test_state_template(hass: HomeAssistant) -> None: async def test_browse_media(hass: HomeAssistant) -> None: """Test browse media.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} ) @@ -1135,6 +1136,7 @@ async def test_browse_media(hass: HomeAssistant) -> None: async def test_browse_media_override(hass: HomeAssistant) -> None: """Test browse media override.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} ) diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index 200cb4b4592..1c0423bb9ad 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -42,7 +42,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/uptime/test_config_flow.py b/tests/components/uptime/test_config_flow.py index 9f1c3931d18..a2234882b27 100644 --- a/tests/components/uptime/test_config_flow.py +++ b/tests/components/uptime/test_config_flow.py @@ -22,7 +22,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index dc945f2c150..3694f0b5803 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -27,7 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/venstar/test_config_flow.py b/tests/components/venstar/test_config_flow.py index f8f66f1b388..521e5a8512e 100644 --- a/tests/components/venstar/test_config_flow.py +++ b/tests/components/venstar/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.venstar.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -105,7 +104,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == SOURCE_USER + assert result["step_id"] == "user" with patch( "homeassistant.components.venstar.VenstarColorTouch.update_info", diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 39cd66a5936..47cce03afe3 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -1,7 +1,72 @@ """Common methods used across tests for VeSync.""" import json -from tests.common import load_fixture +import requests_mock + +from homeassistant.components.vesync.const import DOMAIN + +from tests.common import load_fixture, load_json_object_fixture + +ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) +ALL_DEVICE_NAMES: list[str] = [ + dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] +] +DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { + "Humidifier 200s": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ], + "Humidifier 600S": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ], + "Air Purifier 131s": [ + ("post", "/131airPurifier/v1/device/deviceDetail", "purifier-detail.json") + ], + "Air Purifier 200s": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ], + "Air Purifier 400s": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ], + "Air Purifier 600s": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ], + "Dimmable Light": [ + ("post", "/SmartBulb/v1/device/devicedetail", "device-detail.json") + ], + "Temperature Light": [ + ("post", "/cloud/v1/deviceManaged/bypass", "device-detail.json") + ], + "Outlet": [("get", "/v1/device/outlet/detail", "outlet-detail.json")], + "Wall Switch": [ + ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") + ], + "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], +} + + +def mock_devices_response( + requests_mock: requests_mock.Mocker, device_name: str +) -> None: + """Build a response for the Helpers.call_api method.""" + device_list = [] + for device in ALL_DEVICES["result"]["list"]: + if device["deviceName"] == device_name: + device_list.append(device) + + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", + json={"code": 0, "result": {"list": device_list}}, + ) + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/user/login", + json=load_json_object_fixture("vesync-login.json", DOMAIN), + ) + for fixture in DEVICE_FIXTURES[device_name]: + requests_mock.request( + fixture[0], + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture(fixture[2], DOMAIN), + ) def call_api_side_effect__no_devices(*args, **kwargs): diff --git a/tests/components/vesync/fixtures/device-detail.json b/tests/components/vesync/fixtures/device-detail.json new file mode 100644 index 00000000000..f0cb3033d4c --- /dev/null +++ b/tests/components/vesync/fixtures/device-detail.json @@ -0,0 +1,42 @@ +{ + "code": 0, + "brightNess": "50", + "result": { + "light": { + "brightness": 50, + "colorTempe": 5400 + }, + "result": { + "brightness": 50, + "red": 178.5, + "green": 255, + "blue": 25.5, + "colorMode": "rgb", + + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + + "enabled": true, + "filter_life": 99, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "air_quality": 5, + "air_quality_value": 1, + + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/dimmer-detail.json b/tests/components/vesync/fixtures/dimmer-detail.json new file mode 100644 index 00000000000..6da1b1baa57 --- /dev/null +++ b/tests/components/vesync/fixtures/dimmer-detail.json @@ -0,0 +1,8 @@ +{ + "code": 0, + "deviceStatus": "on", + "activeTime": 100, + "brightness": 50, + "rgbStatus": "on", + "indicatorlightStatus": "on" +} diff --git a/tests/components/vesync/fixtures/outlet-detail.json b/tests/components/vesync/fixtures/outlet-detail.json new file mode 100644 index 00000000000..87b6657a877 --- /dev/null +++ b/tests/components/vesync/fixtures/outlet-detail.json @@ -0,0 +1,7 @@ +{ + "deviceStatus": "on", + "activeTime": 10, + "energy": 100, + "power": 25, + "voltage": 120 +} diff --git a/tests/components/vesync/fixtures/purifier-detail.json b/tests/components/vesync/fixtures/purifier-detail.json new file mode 100644 index 00000000000..de0843975c3 --- /dev/null +++ b/tests/components/vesync/fixtures/purifier-detail.json @@ -0,0 +1,10 @@ +{ + "code": 0, + "deviceStatus": "on", + "activeTime": 50, + "filterLife": 90, + "screenStatus": "on", + "mode": "auto", + "level": 2, + "airQuality": 95 +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json new file mode 100644 index 00000000000..699084507ea --- /dev/null +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -0,0 +1,104 @@ +{ + "code": 0, + "result": { + "list": [ + { + "cid": "200s-humidifier", + "deviceType": "Classic200S", + "deviceName": "Humidifier 200s", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444" + }, + { + "cid": "600s-humidifier", + "deviceType": "LUH-A602S-WUS", + "deviceName": "Humidifier 600S", + "subDeviceNo": null, + "deviceStatus": "off", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-555555555555", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "configModule": "WFON_AHM_LUH-A602S-WUS_US", + "currentFirmVersion": null, + "subDeviceType": null + }, + { + "cid": "air-purifier", + "deviceType": "LV-PUR131S", + "deviceName": "Air Purifier 131s", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55", + "deviceType": "Core200S", + "deviceName": "Air Purifier 200s", + "subDeviceNo": null, + "deviceStatus": "on", + "type": "wifi-air", + "connectionStatus": "online" + }, + { + "cid": "400s-purifier", + "deviceType": "LAP-C401S-WJP", + "deviceName": "Air Purifier 400s", + "subDeviceNo": null, + "deviceStatus": "on", + "type": "wifi-air", + "connectionStatus": "online" + }, + { + "cid": "600s-purifier", + "deviceType": "LAP-C601S-WUS", + "deviceName": "Air Purifier 600s", + "subDeviceNo": null, + "type": "wifi-air", + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "dimmable-bulb", + "deviceType": "ESL100", + "deviceName": "Dimmable Light", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "tunable-bulb", + "deviceType": "ESL100CW", + "deviceName": "Temperature Light", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "outlet", + "deviceType": "wifi-switch-1.3", + "deviceName": "Outlet", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "switch", + "deviceType": "ESWL01", + "deviceName": "Wall Switch", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + }, + { + "cid": "dimmable-switch", + "deviceType": "ESWD16", + "deviceName": "Dimmer Switch", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online" + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync-login.json b/tests/components/vesync/fixtures/vesync-login.json new file mode 100644 index 00000000000..08139034738 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync-login.json @@ -0,0 +1,7 @@ +{ + "code": 0, + "result": { + "token": "test-token", + "accountID": "1234" + } +} diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr new file mode 100644 index 00000000000..82a31b5fc14 --- /dev/null +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -0,0 +1,533 @@ +# serializer version: 1 +# name: test_fan_state[Air Purifier 131s][devices] + 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( + 'vesync', + 'air-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 131s][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_131s', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 131s', + 'platform': 'vesync', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'air-purifier', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 131s][fan.air_purifier_131s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s', + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_131s', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_fan_state[Air Purifier 200s][devices] + 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( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 200s][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'sleep', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_200s', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 200s', + 'platform': 'vesync', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 200s][fan.air_purifier_200s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'Air Purifier 200s', + 'mode': 'manual', + 'night_light': 'off', + 'percentage': 33, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + 'sleep', + ]), + 'screen_status': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_200s', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_state[Air Purifier 400s][devices] + 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( + 'vesync', + '400s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 400s][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_400s', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 400s', + 'platform': 'vesync', + 'supported_features': , + 'translation_key': None, + 'unique_id': '400s-purifier', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 400s][fan.air_purifier_400s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'Air Purifier 400s', + 'mode': 'manual', + 'night_light': 'off', + 'percentage': 25, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'screen_status': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_400s', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_state[Air Purifier 600s][devices] + 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( + 'vesync', + '600s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 600s][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_600s', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 600s', + 'platform': 'vesync', + 'supported_features': , + 'translation_key': None, + 'unique_id': '600s-purifier', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[Air Purifier 600s][fan.air_purifier_600s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'Air Purifier 600s', + 'mode': 'manual', + 'night_light': 'off', + 'percentage': 25, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'screen_status': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_600s', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_state[Dimmable Light][devices] + 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( + 'vesync', + 'dimmable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'name': 'Dimmable Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Dimmable Light][entities] + list([ + ]) +# --- +# name: test_fan_state[Dimmer Switch][devices] + 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( + 'vesync', + 'dimmable-switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Dimmer Switch][entities] + list([ + ]) +# --- +# name: test_fan_state[Humidifier 200s][devices] + list([ + ]) +# --- +# name: test_fan_state[Humidifier 200s][entities] + list([ + ]) +# --- +# name: test_fan_state[Humidifier 600S][devices] + list([ + ]) +# --- +# name: test_fan_state[Humidifier 600S][entities] + list([ + ]) +# --- +# name: test_fan_state[Outlet][devices] + 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( + 'vesync', + 'outlet', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'name': 'Outlet', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Outlet][entities] + list([ + ]) +# --- +# name: test_fan_state[Temperature Light][devices] + 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( + 'vesync', + 'tunable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'name': 'Temperature Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Temperature Light][entities] + list([ + ]) +# --- +# name: test_fan_state[Wall Switch][devices] + 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( + 'vesync', + 'switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'name': 'Wall Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[Wall Switch][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr new file mode 100644 index 00000000000..1f7b0aa9baf --- /dev/null +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -0,0 +1,468 @@ +# serializer version: 1 +# name: test_light_state[Air Purifier 131s][devices] + 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( + 'vesync', + 'air-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Air Purifier 131s][entities] + list([ + ]) +# --- +# name: test_light_state[Air Purifier 200s][devices] + 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( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Air Purifier 200s][entities] + list([ + ]) +# --- +# name: test_light_state[Air Purifier 400s][devices] + 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( + 'vesync', + '400s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Air Purifier 400s][entities] + list([ + ]) +# --- +# name: test_light_state[Air Purifier 600s][devices] + 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( + 'vesync', + '600s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Air Purifier 600s][entities] + list([ + ]) +# --- +# name: test_light_state[Dimmable Light][devices] + 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( + 'vesync', + 'dimmable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'name': 'Dimmable Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Dimmable Light][entities] + list([ + 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.dimmable_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmable Light', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'dimmable-bulb', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_light_state[Dimmable Light][light.dimmable_light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dimmable Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmable_light', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_light_state[Dimmer Switch][devices] + 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( + 'vesync', + 'dimmable-switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Dimmer Switch][entities] + list([ + 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.dimmer_switch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer Switch', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'dimmable-switch', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_light_state[Dimmer Switch][light.dimmer_switch] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 128, + 'color_mode': , + 'friendly_name': 'Dimmer Switch', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_switch', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_state[Humidifier 200s][devices] + list([ + ]) +# --- +# name: test_light_state[Humidifier 200s][entities] + list([ + ]) +# --- +# name: test_light_state[Humidifier 600S][devices] + list([ + ]) +# --- +# name: test_light_state[Humidifier 600S][entities] + list([ + ]) +# --- +# name: test_light_state[Outlet][devices] + 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( + 'vesync', + 'outlet', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'name': 'Outlet', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Outlet][entities] + list([ + ]) +# --- +# name: test_light_state[Temperature Light][devices] + 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( + 'vesync', + 'tunable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'name': 'Temperature Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Temperature Light][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6493, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2702, + 'min_mireds': 154, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.temperature_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Light', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tunable-bulb', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_light_state[Temperature Light][light.temperature_light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Temperature Light', + 'max_color_temp_kelvin': 6493, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2702, + 'min_mireds': 154, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.temperature_light', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_state[Wall Switch][devices] + 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( + 'vesync', + 'switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'name': 'Wall Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[Wall Switch][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..040e41747a2 --- /dev/null +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -0,0 +1,970 @@ +# serializer version: 1 +# name: test_sensor_state[Air Purifier 131s][devices] + 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( + 'vesync', + 'air-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 131s][entities] + 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': , + 'entity_id': 'sensor.air_purifier_131s_filter_life', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 131s Filter Life', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'air-purifier-filter-life', + '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.air_purifier_131s_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 131s Air Quality', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'air-purifier-air-quality', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_air_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s Air Quality', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_131s_air_quality', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_life] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s Filter Life', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_131s_filter_life', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_state[Air Purifier 200s][devices] + 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( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][entities] + 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': , + 'entity_id': 'sensor.air_purifier_200s_filter_life', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 200s Filter Life', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-filter-life', + 'unit_of_measurement': '%', + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_life] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Filter Life', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_200s_filter_life', + 'last_changed': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_state[Air Purifier 400s][devices] + 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( + 'vesync', + '400s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][entities] + 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': , + 'entity_id': 'sensor.air_purifier_400s_filter_life', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 400s Filter Life', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '400s-purifier-filter-life', + '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.air_purifier_400s_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 400s Air Quality', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '400s-purifier-air-quality', + '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.air_purifier_400s_pm2_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air Purifier 400s PM2.5', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '400s-purifier-pm25', + 'unit_of_measurement': 'µg/m³', + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_air_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 400s Air Quality', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_400s_air_quality', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_filter_life] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 400s Filter Life', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_400s_filter_life', + 'last_changed': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_pm2_5] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Air Purifier 400s PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_400s_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor_state[Air Purifier 600s][devices] + 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( + 'vesync', + '600s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][entities] + 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': , + 'entity_id': 'sensor.air_purifier_600s_filter_life', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 600s Filter Life', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-purifier-filter-life', + '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.air_purifier_600s_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Purifier 600s Air Quality', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-purifier-air-quality', + '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.air_purifier_600s_pm2_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air Purifier 600s PM2.5', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-purifier-pm25', + 'unit_of_measurement': 'µg/m³', + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_air_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 600s Air Quality', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_600s_air_quality', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_filter_life] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 600s Filter Life', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_600s_filter_life', + 'last_changed': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_pm2_5] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Air Purifier 600s PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_600s_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor_state[Dimmable Light][devices] + 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( + 'vesync', + 'dimmable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'name': 'Dimmable Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmable Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Dimmer Switch][devices] + 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( + 'vesync', + 'dimmable-switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmer Switch][entities] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 200s][devices] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 200s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 600S][devices] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 600S][entities] + list([ + ]) +# --- +# name: test_sensor_state[Outlet][devices] + 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( + 'vesync', + 'outlet', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'name': 'Outlet', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Outlet][entities] + 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.outlet_current_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet current power', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-power', + '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.outlet_energy_use_today', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet energy use today', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-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.outlet_energy_use_weekly', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet energy use weekly', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-energy-weekly', + '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.outlet_energy_use_monthly', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet energy use monthly', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-energy-monthly', + '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.outlet_energy_use_yearly', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet energy use yearly', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-energy-yearly', + '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.outlet_current_voltage', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outlet current voltage', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet-voltage', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_current_power] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet current power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_current_power', + 'last_changed': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_current_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Outlet current voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_current_voltage', + 'last_changed': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_energy_use_monthly] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Outlet energy use monthly', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_energy_use_monthly', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_energy_use_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Outlet energy use today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_energy_use_today', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_energy_use_weekly] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Outlet energy use weekly', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_energy_use_weekly', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_state[Outlet][sensor.outlet_energy_use_yearly] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Outlet energy use yearly', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_energy_use_yearly', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_state[Temperature Light][devices] + 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( + 'vesync', + 'tunable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'name': 'Temperature Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Temperature Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Wall Switch][devices] + 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( + 'vesync', + 'switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'name': 'Wall Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Wall Switch][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr new file mode 100644 index 00000000000..77f4011a532 --- /dev/null +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -0,0 +1,394 @@ +# serializer version: 1 +# name: test_switch_state[Air Purifier 131s][devices] + 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( + 'vesync', + 'air-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Air Purifier 131s][entities] + list([ + ]) +# --- +# name: test_switch_state[Air Purifier 200s][devices] + 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( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Air Purifier 200s][entities] + list([ + ]) +# --- +# name: test_switch_state[Air Purifier 400s][devices] + 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( + 'vesync', + '400s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Air Purifier 400s][entities] + list([ + ]) +# --- +# name: test_switch_state[Air Purifier 600s][devices] + 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( + 'vesync', + '600s-purifier', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Air Purifier 600s][entities] + list([ + ]) +# --- +# name: test_switch_state[Dimmable Light][devices] + 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( + 'vesync', + 'dimmable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'name': 'Dimmable Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Dimmable Light][entities] + list([ + ]) +# --- +# name: test_switch_state[Dimmer Switch][devices] + 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( + 'vesync', + 'dimmable-switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Dimmer Switch][entities] + list([ + ]) +# --- +# name: test_switch_state[Humidifier 200s][devices] + list([ + ]) +# --- +# name: test_switch_state[Humidifier 200s][entities] + list([ + ]) +# --- +# name: test_switch_state[Humidifier 600S][devices] + list([ + ]) +# --- +# name: test_switch_state[Humidifier 600S][entities] + list([ + ]) +# --- +# name: test_switch_state[Outlet][devices] + 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( + 'vesync', + 'outlet', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'name': 'Outlet', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Outlet][entities] + list([ + 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.outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Outlet', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switch_state[Outlet][switch.outlet] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Outlet', + }), + 'context': , + 'entity_id': 'switch.outlet', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state[Temperature Light][devices] + 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( + 'vesync', + 'tunable-bulb', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'name': 'Temperature Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Temperature Light][entities] + list([ + ]) +# --- +# name: test_switch_state[Wall Switch][devices] + 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( + 'vesync', + 'switch', + ), + }), + 'is_new': False, + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'name': 'Wall Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[Wall Switch][entities] + list([ + 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.wall_switch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wall Switch', + 'platform': 'vesync', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'switch', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switch_state[Wall Switch][switch.wall_switch] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Wall Switch', + }), + 'context': , + 'entity_id': 'switch.wall_switch', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py new file mode 100644 index 00000000000..26ac565e1a6 --- /dev/null +++ b/tests/components/vesync/test_fan.py @@ -0,0 +1,50 @@ +"""Tests for the fan module.""" +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_fan_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(requests_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == FAN_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) diff --git a/tests/components/vesync/test_light.py b/tests/components/vesync/test_light.py new file mode 100644 index 00000000000..b293d6e808e --- /dev/null +++ b/tests/components/vesync/test_light.py @@ -0,0 +1,50 @@ +"""Tests for the light module.""" +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_light_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(requests_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == LIGHT_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py new file mode 100644 index 00000000000..c50b916df66 --- /dev/null +++ b/tests/components/vesync/test_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the sensor module.""" +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_sensor_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(requests_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == SENSOR_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py new file mode 100644 index 00000000000..af721724d91 --- /dev/null +++ b/tests/components/vesync/test_switch.py @@ -0,0 +1,50 @@ +"""Tests for the switch module.""" +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_switch_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + requests_mock: requests_mock.Mocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(requests_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == SWITCH_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index 66cbfdc1d26..8c0f7941ba6 100644 --- a/tests/components/vicare/__init__.py +++ b/tests/components/vicare/__init__.py @@ -6,6 +6,8 @@ from typing import Final from homeassistant.components.vicare.const import CONF_HEATING_TYPE from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME +MODULE = "homeassistant.components.vicare" + ENTRY_CONFIG: Final[dict[str, str]] = { CONF_USERNAME: "foo@bar.com", CONF_PASSWORD: "1234", diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py new file mode 100644 index 00000000000..1137abbc54e --- /dev/null +++ b/tests/components/vicare/conftest.py @@ -0,0 +1,77 @@ +"""Fixtures for ViCare integration tests.""" +from __future__ import annotations + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch + +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +import pytest + +from homeassistant.components.vicare.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG, MODULE + +from tests.common import MockConfigEntry, load_json_object_fixture + + +class MockPyViCare: + """Mocked PyVicare class based on a json dump.""" + + def __init__(self, fixtures: list[str]) -> None: + """Init a single device from json dump.""" + self.devices = [] + for idx, fixture in enumerate(fixtures): + self.devices.append( + PyViCareDeviceConfig( + MockViCareService(fixture), + f"deviceId{idx}", + f"model{idx}", + f"online{idx}", + ) + ) + + +class MockViCareService: + """PyVicareService mock using a json dump.""" + + def __init__(self, fixture: str) -> None: + """Initialize the mock from a json dump.""" + self._test_data = load_json_object_fixture(fixture) + self.fetch_all_features = Mock(return_value=self._test_data) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="ViCare", + entry_id="1234", + data=ENTRY_CONFIG, + ) + + +@pytest.fixture +async def mock_vicare_gas_boiler( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> AsyncGenerator[MockConfigEntry, None]: + """Return a mocked ViCare API representing a single gas boiler device.""" + fixtures = ["vicare/Vitodens300W.json"] + with patch( + f"{MODULE}.vicare_login", + return_value=MockPyViCare(fixtures), + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield mock_config_entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/vicare/fixtures/Vitodens300W.json b/tests/components/vicare/fixtures/Vitodens300W.json new file mode 100644 index 00000000000..4cf67ebe0f7 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitodens300W.json @@ -0,0 +1,3885 @@ +{ + "data": [ + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.total", + "gatewayId": "################", + "feature": "heating.buffer.charging.level.total", + "timestamp": "2021-08-25T03:29:47.707Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["bottom", "middle", "top", "total"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level", + "gatewayId": "################", + "feature": "heating.buffer.charging.level", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps.circuit", + "gatewayId": "################", + "feature": "heating.solar.pumps.circuit", + "timestamp": "2021-08-25T03:29:47.713Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "hours": { + "type": "number", + "value": 18726.3, + "unit": "" + }, + "starts": { + "type": "number", + "value": 14315, + "unit": "" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.statistics", + "gatewayId": "################", + "feature": "heating.burners.0.statistics", + "timestamp": "2021-08-25T14:23:17.238Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes.heating", + "timestamp": "2021-08-25T03:29:46.971Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/device", + "gatewayId": "################", + "feature": "device", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "feature": "heating.dhw.pumps.circulation.schedule", + "timestamp": "2021-08-25T03:29:47.694Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump", + "gatewayId": "################", + "feature": "heating.circuits.0.circulation.pump", + "timestamp": "2021-08-25T03:29:47.639Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pump"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation", + "gatewayId": "################", + "feature": "heating.circuits.2.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule", + "gatewayId": "################", + "feature": "heating.circuits.2.heating.schedule", + "timestamp": "2021-08-25T03:29:46.922Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "feature": "heating.circuits.2.sensors.temperature.supply", + "timestamp": "2021-08-25T03:29:47.572Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "feature": "heating.solar.sensors.temperature.collector", + "timestamp": "2021-08-25T03:29:47.700Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes.active", + "timestamp": "2021-08-25T03:29:47.677Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner", + "gatewayId": "################", + "feature": "heating.burner", + "timestamp": "2021-08-25T14:16:46.543Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs.holiday", + "gatewayId": "################", + "feature": "heating.operating.programs.holiday", + "timestamp": "2021-08-25T03:29:47.714Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.bottom", + "gatewayId": "################", + "feature": "heating.buffer.charging.level.bottom", + "timestamp": "2021-08-25T03:29:47.711Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 63, + "unit": "celsius" + }, + "status": { + "type": "string", + "value": "connected" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "feature": "heating.circuits.0.sensors.temperature.supply", + "timestamp": "2021-08-25T15:13:19.679Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes.dhw", + "timestamp": "2021-08-25T03:29:46.955Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "value": "dhw", + "type": "string" + } + }, + "commands": { + "setMode": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode", + "name": "setMode", + "isExecutable": true, + "params": { + "mode": { + "type": "string", + "required": true, + "constraints": { + "enum": [ + "standby", + "dhw", + "dhwAndHeating", + "forcedReduced", + "forcedNormal" + ] + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes.active", + "timestamp": "2021-08-25T03:29:47.654Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 22, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 4, + "max": 37, + "stepping": 1 + } + } + } + }, + "activate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate", + "name": "activate", + "isExecutable": true, + "params": { + "temperature": { + "type": "number", + "required": false, + "constraints": { + "min": 4, + "max": 37, + "stepping": 1 + } + } + } + }, + "deactivate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate", + "name": "deactivate", + "isExecutable": false, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.comfort", + "timestamp": "2021-08-25T03:29:46.825Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["operating"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation", + "gatewayId": "################", + "feature": "ventilation", + "timestamp": "2021-08-25T03:29:47.717Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 7 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.1 + } + }, + "commands": { + "setCurve": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve", + "name": "setCurve", + "isExecutable": true, + "params": { + "slope": { + "type": "number", + "required": true, + "constraints": { + "min": 0.2, + "max": 3.5, + "stepping": 0.1 + } + }, + "shift": { + "type": "number", + "required": true, + "constraints": { + "min": -13, + "max": 40, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve", + "gatewayId": "################", + "feature": "heating.circuits.1.heating.curve", + "timestamp": "2021-08-25T03:29:46.909Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "timestamp": "2021-08-25T03:29:46.838Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pump"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation", + "gatewayId": "################", + "feature": "heating.circuits.0.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.frostprotection", + "gatewayId": "################", + "feature": "heating.circuits.2.frostprotection", + "timestamp": "2021-08-25T03:29:46.903Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [ + "circulation", + "dhw", + "frostprotection", + "heating", + "operating", + "sensors" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2", + "gatewayId": "################", + "feature": "heating.circuits.2", + "timestamp": "2021-08-25T03:29:46.863Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pumps", "sensors"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar", + "gatewayId": "################", + "feature": "heating.solar", + "timestamp": "2021-08-25T03:29:47.698Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["modes", "programs"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating", + "gatewayId": "################", + "feature": "ventilation.operating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": ["modulation", "statistics"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0", + "gatewayId": "################", + "feature": "heating.burners.0", + "timestamp": "2021-08-25T14:16:46.550Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["modes", "programs"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating", + "gatewayId": "################", + "feature": "heating.circuits.1.operating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.standby", + "timestamp": "2021-08-25T03:29:47.560Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "start": { + "value": "", + "type": "string" + }, + "end": { + "value": "", + "type": "string" + } + }, + "commands": { + "changeEndDate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/changeEndDate", + "name": "changeEndDate", + "isExecutable": false, + "params": { + "end": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + } + } + } + }, + "schedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/schedule", + "name": "schedule", + "isExecutable": true, + "params": { + "start": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + } + }, + "end": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + } + } + } + }, + "unschedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/unschedule", + "name": "unschedule", + "isExecutable": true, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.holiday", + "timestamp": "2021-08-25T03:29:47.541Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standby", + "gatewayId": "################", + "feature": "ventilation.operating.modes.standby", + "timestamp": "2021-08-25T03:29:47.726Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["active", "dhw", "dhwAndHeating", "heating", "standby"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.primary", + "gatewayId": "################", + "feature": "heating.dhw.pumps.primary", + "timestamp": "2021-08-25T14:18:44.841Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.holiday", + "gatewayId": "################", + "feature": "ventilation.operating.programs.holiday", + "timestamp": "2021-08-25T03:29:47.722Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "tue": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "wed": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "thu": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "fri": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "sat": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "sun": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["normal"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "reduced", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule", + "gatewayId": "################", + "feature": "heating.circuits.1.heating.schedule", + "timestamp": "2021-08-25T03:29:46.920Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "timestamp": "2021-08-25T03:29:46.967Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 18, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 3, + "max": 37, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.reduced", + "timestamp": "2021-08-25T03:29:47.553Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["offset"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time", + "gatewayId": "################", + "feature": "heating.device.time", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["curve", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating", + "gatewayId": "################", + "feature": "heating.circuits.0.heating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "start": { + "value": "", + "type": "string" + }, + "end": { + "value": "", + "type": "string" + } + }, + "commands": { + "changeEndDate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/changeEndDate", + "name": "changeEndDate", + "isExecutable": false, + "params": { + "end": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + } + } + } + }, + "schedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/schedule", + "name": "schedule", + "isExecutable": true, + "params": { + "start": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + } + }, + "end": { + "type": "string", + "required": true, + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + } + } + } + }, + "unschedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/unschedule", + "name": "unschedule", + "isExecutable": true, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.holiday", + "timestamp": "2021-08-25T03:29:47.543Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "value": "dhw", + "type": "string" + } + }, + "commands": { + "setMode": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode", + "name": "setMode", + "isExecutable": true, + "params": { + "mode": { + "type": "string", + "required": true, + "constraints": { + "enum": [ + "standby", + "dhw", + "dhwAndHeating", + "forcedReduced", + "forcedNormal" + ] + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes.active", + "timestamp": "2021-08-25T03:29:47.666Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "tue": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "wed": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "thu": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "fri": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "sat": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ], + "sun": [ + { + "start": "06:00", + "end": "22:00", + "mode": "normal", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["normal"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "reduced", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule", + "gatewayId": "################", + "feature": "heating.circuits.0.heating.schedule", + "timestamp": "2021-08-25T03:29:46.918Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.controller.serial", + "gatewayId": "################", + "feature": "heating.controller.serial", + "timestamp": "2021-08-25T03:29:47.574Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "temperature": { + "value": 0, + "unit": "", + "type": "number" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.external", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.external", + "timestamp": "2021-08-25T03:29:47.536Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "name": { + "value": "", + "type": "string" + }, + "type": { + "value": "heatingCircuit", + "type": "string" + } + }, + "commands": { + "setName": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName", + "name": "setName", + "isExecutable": true, + "params": { + "name": { + "type": "string", + "required": true, + "constraints": { + "minLength": 1, + "maxLength": 20 + } + } + } + } + }, + "components": [ + "circulation", + "dhw", + "frostprotection", + "heating", + "operating", + "sensors" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0", + "gatewayId": "################", + "feature": "heating.circuits.0", + "timestamp": "2021-08-25T03:29:46.859Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes.dhw", + "timestamp": "2021-08-25T03:29:46.939Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation", + "gatewayId": "################", + "feature": "heating.circuits.0.dhw.pumps.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [ + "active", + "comfort", + "eco", + "external", + "holiday", + "normal", + "reduced", + "standby" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["room", "supply"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature", + "gatewayId": "################", + "feature": "heating.circuits.1.sensors.temperature", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.frostprotection", + "gatewayId": "################", + "feature": "heating.circuits.0.frostprotection", + "timestamp": "2021-08-25T03:29:46.894Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "timestamp": "2021-08-25T03:29:46.958Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["programs"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating", + "gatewayId": "################", + "feature": "heating.operating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [ + "boiler", + "buffer", + "burner", + "burners", + "circuits", + "configuration", + "device", + "dhw", + "operating", + "sensors", + "solar" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating", + "gatewayId": "################", + "feature": "heating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["0"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners", + "gatewayId": "################", + "feature": "heating.burners", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation", + "gatewayId": "################", + "feature": "heating.circuits.2.dhw.pumps.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["circuit"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps", + "gatewayId": "################", + "feature": "heating.solar.pumps", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.top", + "gatewayId": "################", + "feature": "heating.buffer.charging.level.top", + "timestamp": "2021-08-25T03:29:47.708Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors", + "gatewayId": "################", + "feature": "heating.solar.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["sensors", "serial", "temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler", + "gatewayId": "################", + "feature": "heating.boiler", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.holiday", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.holiday", + "timestamp": "2021-08-25T03:29:47.545Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 20.8, + "unit": "celsius" + }, + "status": { + "type": "string", + "value": "connected" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature.outside", + "gatewayId": "################", + "feature": "heating.sensors.temperature.outside", + "timestamp": "2021-08-25T15:07:33.251Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "feature": "heating.circuits.2.sensors.temperature.room", + "timestamp": "2021-08-25T03:29:47.566Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["modes", "programs"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating", + "gatewayId": "################", + "feature": "heating.circuits.0.operating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "kilowattHour", + "type": "string" + }, + "day": { + "type": "array", + "value": [0.219, 0.316, 0.32, 0.325, 0.311, 0.317, 0.312, 0.313] + }, + "week": { + "type": "array", + "value": [ + 0.829, 2.241, 2.22, 2.233, 2.23, 2.23, 2.227, 2.008, 2.198, 2.236, + 2.159, 2.255, 2.497, 6.849, 7.213, 6.749, 7.994, 7.958, 8.397, + 8.728, 8.743, 7.453, 8.386, 8.839, 8.763, 8.678, 7.896, 8.783, + 9.821, 8.683, 9, 8.738, 9.027, 8.974, 8.882, 8.286, 8.448, 8.785, + 8.704, 8.053, 7.304, 7.078, 7.251, 6.839, 6.902, 7.042, 6.864, + 6.818, 3.938, 2.308, 2.283, 2.246, 2.269 + ] + }, + "month": { + "type": "array", + "value": [ + 7.843, 9.661, 9.472, 31.747, 35.805, 37.785, 35.183, 39.583, 37.998, + 31.939, 30.552, 13.375, 9.734 + ] + }, + "year": { + "type": "array", + "value": [207.106, 311.579, 320.275] + }, + "dayValueReadAt": { + "type": "string", + "value": "2021-08-25T15:10:12.179Z" + }, + "weekValueReadAt": { + "type": "string", + "value": "2021-08-25T13:22:51.623Z" + }, + "monthValueReadAt": { + "type": "string", + "value": "2021-08-25T13:22:54.009Z" + }, + "yearValueReadAt": { + "type": "string", + "value": "2021-08-25T15:13:33.507Z" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption.total", + "gatewayId": "################", + "feature": "heating.power.consumption.total", + "timestamp": "2021-08-25T15:13:35.950Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pumps", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw", + "gatewayId": "################", + "feature": "heating.circuits.2.dhw", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.active", + "gatewayId": "################", + "feature": "ventilation.operating.modes.active", + "timestamp": "2021-08-25T03:29:47.724Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "name": { + "value": "", + "type": "string" + }, + "type": { + "value": "heatingCircuit", + "type": "string" + } + }, + "commands": { + "setName": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1/commands/setName", + "name": "setName", + "isExecutable": true, + "params": { + "name": { + "type": "string", + "required": true, + "constraints": { + "minLength": 1, + "maxLength": 20 + } + } + } + } + }, + "components": [ + "circulation", + "dhw", + "frostprotection", + "heating", + "operating", + "sensors" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1", + "gatewayId": "################", + "feature": "heating.circuits.1", + "timestamp": "2021-08-25T03:29:46.861Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "kilowattHour", + "type": "string" + }, + "day": { + "type": "array", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "week": { + "type": "array", + "value": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 544, 806, 636, 1153, 1081, + 1275, 1582, 1594, 888, 1353, 1678, 1588, 1507, 1093, 1687, 2679, + 1647, 1916, 1668, 1870, 1877, 1785, 1325, 1351, 1718, 1597, 1220, + 706, 562, 653, 429, 442, 629, 435, 414, 149, 0, 0, 0, 0 + ] + }, + "month": { + "type": "array", + "value": [ + 0, 0, 0, 3508, 5710, 6491, 7106, 8131, 6728, 3438, 2113, 336, 0 + ] + }, + "year": { + "type": "array", + "value": [30946, 32288, 37266] + }, + "dayValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:37.198Z" + }, + "weekValueReadAt": { + "type": "string", + "value": "2021-08-23T01:22:41.933Z" + }, + "monthValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:42.956Z" + }, + "yearValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:38.203Z" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.heating", + "gatewayId": "################", + "feature": "heating.gas.consumption.heating", + "timestamp": "2021-08-25T03:29:47.627Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.reduced", + "timestamp": "2021-08-25T03:29:47.556Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "tue": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "wed": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "thu": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "fri": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sat": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sun": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["on"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "off", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "feature": "heating.circuits.0.dhw.pumps.circulation.schedule", + "timestamp": "2021-08-25T03:29:46.866Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.standard", + "gatewayId": "################", + "feature": "ventilation.operating.programs.standard", + "timestamp": "2021-08-25T03:29:47.719Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation", + "gatewayId": "################", + "feature": "heating.circuits.1.dhw.pumps.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "04:30", + "end": "10:00", + "mode": "on", + "position": 0 + }, + { + "start": "16:30", + "end": "24:00", + "mode": "on", + "position": 1 + } + ], + "tue": [ + { + "start": "04:30", + "end": "10:00", + "mode": "on", + "position": 0 + }, + { + "start": "16:30", + "end": "24:00", + "mode": "on", + "position": 1 + } + ], + "wed": [ + { + "start": "04:30", + "end": "10:00", + "mode": "on", + "position": 0 + }, + { + "start": "16:30", + "end": "24:00", + "mode": "on", + "position": 1 + } + ], + "thu": [ + { + "start": "04:30", + "end": "10:00", + "mode": "on", + "position": 0 + }, + { + "start": "16:30", + "end": "24:00", + "mode": "on", + "position": 1 + } + ], + "fri": [ + { + "start": "04:30", + "end": "10:00", + "mode": "on", + "position": 0 + }, + { + "start": "16:30", + "end": "24:00", + "mode": "on", + "position": 1 + } + ], + "sat": [ + { + "start": "06:30", + "end": "24:00", + "mode": "on", + "position": 0 + } + ], + "sun": [ + { + "start": "06:30", + "end": "24:00", + "mode": "on", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["on"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "off", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule", + "gatewayId": "################", + "feature": "heating.circuits.1.dhw.schedule", + "timestamp": "2021-08-25T03:29:46.883Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["circulation"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps", + "gatewayId": "################", + "feature": "heating.circuits.2.dhw.pumps", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.external", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.external", + "timestamp": "2021-08-25T03:29:47.540Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["multiFamilyHouse"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration", + "gatewayId": "################", + "feature": "heating.configuration", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pumps", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw", + "gatewayId": "################", + "feature": "heating.circuits.1.dhw", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.eco", + "gatewayId": "################", + "feature": "ventilation.operating.programs.eco", + "timestamp": "2021-08-25T03:29:47.720Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 5, + "unit": "celsius" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.temperature", + "gatewayId": "################", + "feature": "heating.boiler.temperature", + "timestamp": "2021-08-25T14:16:46.376Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.serial", + "gatewayId": "################", + "feature": "heating.boiler.serial", + "timestamp": "2021-08-25T03:29:46.840Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["curve", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating", + "gatewayId": "################", + "feature": "heating.circuits.1.heating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "commands": {}, + "components": ["schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation", + "gatewayId": "################", + "feature": "heating.dhw.pumps.circulation", + "timestamp": "2021-08-25T03:29:47.609Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration.multiFamilyHouse", + "gatewayId": "################", + "feature": "heating.configuration.multiFamilyHouse", + "timestamp": "2021-08-25T03:29:47.693Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [ + "active", + "comfort", + "eco", + "external", + "holiday", + "normal", + "reduced", + "standby" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["modes", "programs"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating", + "gatewayId": "################", + "feature": "heating.circuits.2.operating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes.standby", + "timestamp": "2021-08-25T03:29:47.533Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.standby", + "timestamp": "2021-08-25T03:29:47.558Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation", + "gatewayId": "################", + "feature": "ventilation.operating.modes.ventilation", + "timestamp": "2021-08-25T03:29:47.729Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["curve", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating", + "gatewayId": "################", + "feature": "heating.circuits.2.heating", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "feature": "heating.circuits.2.dhw.pumps.circulation.schedule", + "timestamp": "2021-08-25T03:29:46.876Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 23, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 3, + "max": 37, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.normal", + "timestamp": "2021-08-25T03:29:47.548Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 21, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 3, + "max": 37, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.normal", + "timestamp": "2021-08-25T03:29:47.546Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "timestamp": "2021-08-25T03:29:46.963Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.active", + "timestamp": "2021-08-25T03:29:47.649Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes.dhw", + "timestamp": "2021-08-25T03:29:46.933Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.schedule", + "gatewayId": "################", + "feature": "heating.circuits.2.dhw.schedule", + "timestamp": "2021-08-25T03:29:46.890Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 24, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 4, + "max": 37, + "stepping": 1 + } + } + } + }, + "activate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/activate", + "name": "activate", + "isExecutable": true, + "params": { + "temperature": { + "type": "number", + "required": false, + "constraints": { + "min": 4, + "max": 37, + "stepping": 1 + } + } + } + }, + "deactivate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/deactivate", + "name": "deactivate", + "isExecutable": false, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.comfort", + "timestamp": "2021-08-25T03:29:46.827Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.standby", + "timestamp": "2021-08-25T03:29:47.559Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 9 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.4 + } + }, + "commands": { + "setCurve": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve", + "name": "setCurve", + "isExecutable": true, + "params": { + "slope": { + "type": "number", + "required": true, + "constraints": { + "min": 0.2, + "max": 3.5, + "stepping": 0.1 + } + }, + "shift": { + "type": "number", + "required": true, + "constraints": { + "min": -13, + "max": 40, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve", + "gatewayId": "################", + "feature": "heating.circuits.0.heating.curve", + "timestamp": "2021-08-25T03:29:46.906Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.eco", + "timestamp": "2021-08-25T03:29:47.552Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "kilowattHour", + "type": "string" + }, + "day": { + "type": "array", + "value": [22, 33, 32, 34, 32, 32, 32, 32] + }, + "week": { + "type": "array", + "value": [ + 84, 232, 226, 230, 230, 226, 229, 214, 229, 229, 220, 229, 229, 250, + 244, 247, 266, 268, 268, 255, 248, 247, 242, 244, 248, 250, 238, + 242, 259, 256, 259, 263, 255, 241, 257, 250, 237, 240, 243, 253, + 257, 253, 258, 261, 254, 254, 256, 258, 240, 240, 230, 223, 231 + ] + }, + "month": { + "type": "array", + "value": [ + 805, 1000, 968, 1115, 1109, 1087, 995, 1124, 1087, 1094, 1136, 1009, + 966 + ] + }, + "year": { + "type": "array", + "value": [8203, 12546, 11741] + }, + "dayValueReadAt": { + "type": "string", + "value": "2021-08-25T14:16:40.084Z" + }, + "weekValueReadAt": { + "type": "string", + "value": "2021-08-25T13:22:47.418Z" + }, + "monthValueReadAt": { + "type": "string", + "value": "2021-08-25T13:22:47.985Z" + }, + "yearValueReadAt": { + "type": "string", + "value": "2021-08-25T13:22:51.902Z" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.dhw", + "gatewayId": "################", + "feature": "heating.gas.consumption.dhw", + "timestamp": "2021-08-25T14:16:41.758Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors", + "gatewayId": "################", + "feature": "heating.circuits.1.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "enabled": { + "value": ["0", "1"], + "type": "array" + } + }, + "commands": {}, + "components": ["0", "1", "2"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits", + "gatewayId": "################", + "feature": "heating.circuits", + "timestamp": "2021-08-25T03:29:46.864Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "string", + "value": "standby" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.active", + "timestamp": "2021-08-25T03:29:47.643Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.power.production", + "gatewayId": "################", + "feature": "heating.solar.power.production", + "timestamp": "2021-08-25T03:29:47.634Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors", + "gatewayId": "################", + "feature": "heating.circuits.2.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "temperature": { + "value": 21, + "unit": "", + "type": "number" + } + }, + "commands": { + "activate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate", + "name": "activate", + "isExecutable": false, + "params": {} + }, + "deactivate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate", + "name": "deactivate", + "isExecutable": false, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.programs.eco", + "timestamp": "2021-08-25T03:29:47.547Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.normal", + "timestamp": "2021-08-25T03:29:47.551Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "commands": {}, + "components": [ + "charging", + "oneTimeCharge", + "schedule", + "sensors", + "temperature" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw", + "gatewayId": "################", + "feature": "heating.dhw", + "timestamp": "2021-08-25T03:29:47.650Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump", + "gatewayId": "################", + "feature": "heating.circuits.2.circulation.pump", + "timestamp": "2021-08-25T03:29:47.642Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 63, + "unit": "celsius" + }, + "status": { + "type": "string", + "value": "connected" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.main", + "gatewayId": "################", + "feature": "heating.boiler.sensors.temperature.main", + "timestamp": "2021-08-25T15:13:19.598Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump", + "gatewayId": "################", + "feature": "heating.circuits.1.circulation.pump", + "timestamp": "2021-08-25T03:29:47.641Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "temperature": { + "value": 23, + "unit": "", + "type": "number" + } + }, + "commands": { + "activate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/activate", + "name": "activate", + "isExecutable": false, + "params": {} + }, + "deactivate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/deactivate", + "name": "deactivate", + "isExecutable": false, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.eco", + "timestamp": "2021-08-25T03:29:47.549Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "number", + "value": 0, + "unit": "" + }, + "top": { + "type": "number", + "value": 0, + "unit": "" + }, + "middle": { + "type": "number", + "value": 0, + "unit": "" + }, + "bottom": { + "type": "number", + "value": 0, + "unit": "" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging.level", + "gatewayId": "################", + "feature": "heating.dhw.charging.level", + "timestamp": "2021-08-25T03:29:47.603Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pump"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation", + "gatewayId": "################", + "feature": "heating.circuits.1.circulation", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standard", + "gatewayId": "################", + "feature": "ventilation.operating.modes.standard", + "timestamp": "2021-08-25T03:29:47.728Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["holiday"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs", + "gatewayId": "################", + "feature": "heating.operating.programs", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "tue": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "wed": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "thu": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "fri": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sat": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sun": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["on"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "off", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule", + "gatewayId": "################", + "feature": "heating.circuits.0.dhw.schedule", + "timestamp": "2021-08-25T03:29:46.880Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["eco", "holiday", "standard"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs", + "gatewayId": "################", + "feature": "ventilation.operating.programs", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": true, + "type": "boolean" + }, + "entries": { + "value": { + "mon": [ + { + "start": "05:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "tue": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "wed": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "thu": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "fri": [ + { + "start": "04:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sat": [ + { + "start": "05:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ], + "sun": [ + { + "start": "06:30", + "end": "20:00", + "mode": "on", + "position": 0 + } + ] + }, + "type": "Schedule" + } + }, + "commands": { + "setSchedule": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule/commands/setSchedule", + "name": "setSchedule", + "isExecutable": true, + "params": { + "newSchedule": { + "type": "Schedule", + "required": true, + "constraints": { + "modes": ["on"], + "maxEntries": 4, + "resolution": 10, + "defaultMode": "off", + "overlapAllowed": true + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "feature": "heating.circuits.1.dhw.pumps.circulation.schedule", + "timestamp": "2021-08-25T03:29:46.871Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["room", "supply"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature", + "gatewayId": "################", + "feature": "heating.circuits.0.sensors.temperature", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.middle", + "gatewayId": "################", + "feature": "heating.buffer.charging.level.middle", + "timestamp": "2021-08-25T03:29:47.710Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes.standby", + "timestamp": "2021-08-25T03:29:47.508Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "value": 58, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTargetTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature", + "name": "setTargetTemperature", + "isExecutable": true, + "params": { + "temperature": { + "type": "number", + "required": true, + "constraints": { + "min": 10, + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main", + "gatewayId": "################", + "feature": "heating.dhw.temperature.main", + "timestamp": "2021-08-25T03:29:46.819Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + } + }, + "commands": { + "activate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate", + "name": "activate", + "isExecutable": true, + "params": {} + }, + "deactivate": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate", + "name": "deactivate", + "isExecutable": false, + "params": {} + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge", + "gatewayId": "################", + "feature": "heating.dhw.oneTimeCharge", + "timestamp": "2021-08-25T03:29:47.607Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "kilowattHour", + "type": "string" + }, + "day": { + "type": "array", + "value": [22, 33, 32, 34, 32, 32, 32, 32] + }, + "week": { + "type": "array", + "value": [ + 84, 232, 226, 230, 230, 226, 229, 214, 229, 229, 220, 229, 253, 794, + 1050, 883, 1419, 1349, 1543, 1837, 1842, 1135, 1595, 1922, 1836, + 1757, 1331, 1929, 2938, 1903, 2175, 1931, 2125, 2118, 2042, 1575, + 1588, 1958, 1840, 1473, 963, 815, 911, 690, 696, 883, 691, 672, 389, + 240, 230, 223, 231 + ] + }, + "month": { + "type": "array", + "value": [ + 805, 1000, 968, 4623, 6819, 7578, 8101, 9255, 7815, 4532, 3249, + 1345, 966 + ] + }, + "year": { + "type": "array", + "value": [39149, 44834, 49007] + }, + "dayValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:37.198Z" + }, + "weekValueReadAt": { + "type": "string", + "value": "2021-08-23T01:22:41.933Z" + }, + "monthValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:42.956Z" + }, + "yearValueReadAt": { + "type": "string", + "value": "2021-08-18T21:22:38.203Z" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.total", + "gatewayId": "################", + "feature": "heating.gas.consumption.total", + "timestamp": "2021-08-25T14:16:41.785Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors", + "gatewayId": "################", + "feature": "heating.circuits.0.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "percent", + "type": "string" + }, + "value": { + "type": "number", + "value": 0, + "unit": "percent" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.modulation", + "gatewayId": "################", + "feature": "heating.burners.0.modulation", + "timestamp": "2021-08-25T14:16:46.499Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["total"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption", + "gatewayId": "################", + "feature": "heating.power.consumption", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "demand": { + "value": "unknown", + "type": "string" + }, + "temperature": { + "value": 21, + "unit": "", + "type": "number" + } + }, + "commands": { + "setTemperature": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced/commands/setTemperature", + "name": "setTemperature", + "isExecutable": true, + "params": { + "targetTemperature": { + "type": "number", + "required": true, + "constraints": { + "min": 3, + "max": 37, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.reduced", + "timestamp": "2021-08-25T03:29:47.555Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["outside"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature", + "gatewayId": "################", + "feature": "heating.sensors.temperature", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "feature": "heating.circuits.1.sensors.temperature.room", + "timestamp": "2021-08-25T03:29:47.564Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors", + "gatewayId": "################", + "feature": "heating.boiler.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["collector", "dhw"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature", + "gatewayId": "################", + "feature": "heating.solar.sensors.temperature", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": ["level"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging", + "gatewayId": "################", + "feature": "heating.dhw.charging", + "timestamp": "2021-08-25T14:16:41.453Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes.standby", + "timestamp": "2021-08-25T03:29:47.524Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["charging"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer", + "gatewayId": "################", + "feature": "heating.buffer", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["main"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature", + "gatewayId": "################", + "feature": "heating.dhw.temperature", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "string", + "value": "standby" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.active", + "timestamp": "2021-08-25T03:29:47.645Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.schedule", + "gatewayId": "################", + "feature": "heating.dhw.schedule", + "timestamp": "2021-08-25T03:29:47.695Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["level"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging", + "gatewayId": "################", + "feature": "heating.buffer.charging", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.programs.comfort", + "timestamp": "2021-08-25T03:29:46.830Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["active", "dhw", "dhwAndHeating", "heating", "standby"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes", + "gatewayId": "################", + "feature": "heating.circuits.0.operating.modes", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["active", "standard", "standby", "ventilation"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes", + "gatewayId": "################", + "feature": "ventilation.operating.modes", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["circulation"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps", + "gatewayId": "################", + "feature": "heating.circuits.1.dhw.pumps", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [ + "active", + "comfort", + "eco", + "external", + "holiday", + "normal", + "reduced", + "standby" + ], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "feature": "heating.circuits.2.operating.modes.heating", + "timestamp": "2021-08-25T03:29:46.978Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["room", "supply"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature", + "gatewayId": "################", + "feature": "heating.circuits.2.sensors.temperature", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors", + "gatewayId": "################", + "feature": "heating.dhw.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "status": { + "type": "string", + "value": "error" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "feature": "heating.dhw.sensors.temperature.outlet", + "timestamp": "2021-08-25T03:29:47.637Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["time"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device", + "gatewayId": "################", + "feature": "heating.device", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["temperature"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors", + "gatewayId": "################", + "feature": "heating.sensors", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "value": { + "type": "number", + "value": 96, + "unit": "" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time.offset", + "gatewayId": "################", + "feature": "heating.device.time.offset", + "timestamp": "2021-08-25T03:29:47.575Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "feature": "heating.circuits.0.sensors.temperature.room", + "timestamp": "2021-08-25T03:29:47.562Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["circulation"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps", + "gatewayId": "################", + "feature": "heating.circuits.0.dhw.pumps", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.frostprotection", + "gatewayId": "################", + "feature": "heating.circuits.1.frostprotection", + "timestamp": "2021-08-25T03:29:46.900Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "feature": "heating.solar.sensors.temperature.dhw", + "timestamp": "2021-08-25T03:29:47.633Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["pumps", "schedule"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw", + "gatewayId": "################", + "feature": "heating.circuits.0.dhw", + "timestamp": "2021-08-25T03:29:46.400Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.4 + } + }, + "commands": { + "setCurve": { + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve/commands/setCurve", + "name": "setCurve", + "isExecutable": true, + "params": { + "slope": { + "type": "number", + "required": true, + "constraints": { + "min": 0.2, + "max": 3.5, + "stepping": 0.1 + } + }, + "shift": { + "type": "number", + "required": true, + "constraints": { + "min": -13, + "max": 40, + "stepping": 1 + } + } + } + } + }, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve", + "gatewayId": "################", + "feature": "heating.circuits.2.heating.curve", + "timestamp": "2021-08-25T03:29:46.910Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes.heating", + "timestamp": "2021-08-25T03:29:46.975Z", + "isEnabled": false, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "active": { + "value": false, + "type": "boolean" + }, + "temperature": { + "value": 0, + "unit": "", + "type": "number" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.external", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.programs.external", + "timestamp": "2021-08-25T03:29:47.538Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 58.6, + "unit": "celsius" + }, + "status": { + "type": "string", + "value": "connected" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "timestamp": "2021-08-25T15:02:49.557Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": { + "unit": { + "value": "celsius", + "type": "string" + }, + "value": { + "type": "number", + "value": 25.5, + "unit": "celsius" + }, + "status": { + "type": "string", + "value": "connected" + } + }, + "commands": {}, + "components": [], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "feature": "heating.circuits.1.sensors.temperature.supply", + "timestamp": "2021-08-25T11:03:00.515Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + }, + { + "properties": {}, + "commands": {}, + "components": ["active", "dhw", "dhwAndHeating", "heating", "standby"], + "apiVersion": 1, + "uri": "https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes", + "gatewayId": "################", + "feature": "heating.circuits.1.operating.modes", + "timestamp": "2021-08-25T03:29:46.401Z", + "isEnabled": true, + "isReady": true, + "deviceId": "0" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_config_flow.ambr b/tests/components/vicare/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..e99eda8234e --- /dev/null +++ b/tests/components/vicare/snapshots/test_config_flow.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_form_dhcp + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- +# name: test_user_create_entry + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1e80bb26fe7 --- /dev/null +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -0,0 +1,4716 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': list([ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.total', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.707Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.total', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'bottom', + 'middle', + 'top', + 'total', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.pumps.circuit', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.713Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps.circuit', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0.statistics', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'hours': dict({ + 'type': 'number', + 'unit': '', + 'value': 18726.3, + }), + 'starts': dict({ + 'type': 'number', + 'unit': '', + 'value': 14315, + }), + }), + 'timestamp': '2021-08-25T14:23:17.238Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.statistics', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.971Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'device', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/device', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.694Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.circulation.pump', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:47.639Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.922Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.572Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature.collector', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.700Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.677Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burner', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T14:16:46.543Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.714Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.bottom', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.711Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.bottom', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 63, + }), + }), + 'timestamp': '2021-08-25T15:13:19.679Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.955Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setMode': dict({ + 'isExecutable': True, + 'name': 'setMode', + 'params': dict({ + 'mode': dict({ + 'constraints': dict({ + 'enum': list([ + 'standby', + 'dhw', + 'dhwAndHeating', + 'forcedReduced', + 'forcedNormal', + ]), + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'dhw', + }), + }), + 'timestamp': '2021-08-25T03:29:47.654Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': False, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate', + }), + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 22, + }), + }), + 'timestamp': '2021-08-25T03:29:46.825Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'operating', + ]), + 'deviceId': '0', + 'feature': 'ventilation', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.717Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 7, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.1, + }), + }), + 'timestamp': '2021-08-25T03:29:46.909Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors.temperature.commonSupply', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.838Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.frostprotection', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.903Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.frostprotection', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.863Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.solar', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.698Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modulation', + 'statistics', + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T14:16:46.550Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.560Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'changeEndDate': dict({ + 'isExecutable': False, + 'name': 'changeEndDate', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/changeEndDate', + }), + 'schedule': dict({ + 'isExecutable': True, + 'name': 'schedule', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + 'start': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/schedule', + }), + 'unschedule': dict({ + 'isExecutable': True, + 'name': 'unschedule', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/unschedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'end': dict({ + 'type': 'string', + 'value': '', + }), + 'start': dict({ + 'type': 'string', + 'value': '', + }), + }), + 'timestamp': '2021-08-25T03:29:47.541Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.726Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.primary', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T14:18:44.841Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.primary', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.722Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'reduced', + 'maxEntries': 4, + 'modes': list([ + 'normal', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'mon': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sat': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sun': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'thu': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'tue': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'wed': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.920Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.967Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 18, + }), + }), + 'timestamp': '2021-08-25T03:29:47.553Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'offset', + ]), + 'deviceId': '0', + 'feature': 'heating.device.time', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'changeEndDate': dict({ + 'isExecutable': False, + 'name': 'changeEndDate', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/changeEndDate', + }), + 'schedule': dict({ + 'isExecutable': True, + 'name': 'schedule', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + 'start': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/schedule', + }), + 'unschedule': dict({ + 'isExecutable': True, + 'name': 'unschedule', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/unschedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'end': dict({ + 'type': 'string', + 'value': '', + }), + 'start': dict({ + 'type': 'string', + 'value': '', + }), + }), + 'timestamp': '2021-08-25T03:29:47.543Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setMode': dict({ + 'isExecutable': True, + 'name': 'setMode', + 'params': dict({ + 'mode': dict({ + 'constraints': dict({ + 'enum': list([ + 'standby', + 'dhw', + 'dhwAndHeating', + 'forcedReduced', + 'forcedNormal', + ]), + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'dhw', + }), + }), + 'timestamp': '2021-08-25T03:29:47.666Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'reduced', + 'maxEntries': 4, + 'modes': list([ + 'normal', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'mon': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sat': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sun': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'thu': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'tue': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'wed': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.918Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.controller.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': '################', + }), + }), + 'timestamp': '2021-08-25T03:29:47.574Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.controller.serial', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T03:29:47.536Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.external', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setName': dict({ + 'isExecutable': True, + 'name': 'setName', + 'params': dict({ + 'name': dict({ + 'constraints': dict({ + 'maxLength': 20, + 'minLength': 1, + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName', + }), + }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'name': dict({ + 'type': 'string', + 'value': '', + }), + 'type': dict({ + 'type': 'string', + 'value': 'heatingCircuit', + }), + }), + 'timestamp': '2021-08-25T03:29:46.859Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:46.939Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.frostprotection', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:46.894Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.frostprotection', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:46.958Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'boiler', + 'buffer', + 'burner', + 'burners', + 'circuits', + 'configuration', + 'device', + 'dhw', + 'operating', + 'sensors', + 'solar', + ]), + 'deviceId': '0', + 'feature': 'heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + '0', + ]), + 'deviceId': '0', + 'feature': 'heating.burners', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circuit', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.top', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.708Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.top', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'sensors', + 'serial', + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.boiler', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.545Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.sensors.temperature.outside', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 20.8, + }), + }), + 'timestamp': '2021-08-25T15:07:33.251Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature.outside', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.566Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.power.consumption.total', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 0.219, + 0.316, + 0.32, + 0.325, + 0.311, + 0.317, + 0.312, + 0.313, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T15:10:12.179Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 7.843, + 9.661, + 9.472, + 31.747, + 35.805, + 37.785, + 35.183, + 39.583, + 37.998, + 31.939, + 30.552, + 13.375, + 9.734, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:54.009Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 0.829, + 2.241, + 2.22, + 2.233, + 2.23, + 2.23, + 2.227, + 2.008, + 2.198, + 2.236, + 2.159, + 2.255, + 2.497, + 6.849, + 7.213, + 6.749, + 7.994, + 7.958, + 8.397, + 8.728, + 8.743, + 7.453, + 8.386, + 8.839, + 8.763, + 8.678, + 7.896, + 8.783, + 9.821, + 8.683, + 9, + 8.738, + 9.027, + 8.974, + 8.882, + 8.286, + 8.448, + 8.785, + 8.704, + 8.053, + 7.304, + 7.078, + 7.251, + 6.839, + 6.902, + 7.042, + 6.864, + 6.818, + 3.938, + 2.308, + 2.283, + 2.246, + 2.269, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:51.623Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 207.106, + 311.579, + 320.275, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T15:13:33.507Z', + }), + }), + 'timestamp': '2021-08-25T15:13:35.950Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption.total', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.724Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setName': dict({ + 'isExecutable': True, + 'name': 'setName', + 'params': dict({ + 'name': dict({ + 'constraints': dict({ + 'maxLength': 20, + 'minLength': 1, + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1/commands/setName', + }), + }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'name': dict({ + 'type': 'string', + 'value': '', + }), + 'type': dict({ + 'type': 'string', + 'value': 'heatingCircuit', + }), + }), + 'timestamp': '2021-08-25T03:29:46.861Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:37.198Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 3508, + 5710, + 6491, + 7106, + 8131, + 6728, + 3438, + 2113, + 336, + 0, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:42.956Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 24, + 544, + 806, + 636, + 1153, + 1081, + 1275, + 1582, + 1594, + 888, + 1353, + 1678, + 1588, + 1507, + 1093, + 1687, + 2679, + 1647, + 1916, + 1668, + 1870, + 1877, + 1785, + 1325, + 1351, + 1718, + 1597, + 1220, + 706, + 562, + 653, + 429, + 442, + 629, + 435, + 414, + 149, + 0, + 0, + 0, + 0, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-23T01:22:41.933Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 30946, + 32288, + 37266, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:38.203Z', + }), + }), + 'timestamp': '2021-08-25T03:29:47.627Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.556Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.866Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.standard', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.719Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.standard', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.883Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.540Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.external', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'multiFamilyHouse', + ]), + 'deviceId': '0', + 'feature': 'heating.configuration', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.720Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 5, + }), + }), + 'timestamp': '2021-08-25T14:16:46.376Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': '################', + }), + }), + 'timestamp': '2021-08-25T03:29:46.840Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.serial', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'on', + }), + }), + 'timestamp': '2021-08-25T03:29:47.609Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.configuration.multiFamilyHouse', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.693Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration.multiFamilyHouse', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.533Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.558Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.ventilation', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.729Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.876Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 23, + }), + }), + 'timestamp': '2021-08-25T03:29:47.548Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.546Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:46.963Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.649Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:46.933Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.890Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': False, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/deactivate', + }), + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 24, + }), + }), + 'timestamp': '2021-08-25T03:29:46.827Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.559Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 9, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.4, + }), + }), + 'timestamp': '2021-08-25T03:29:46.906Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.552Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 22, + 33, + 32, + 34, + 32, + 32, + 32, + 32, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T14:16:40.084Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 805, + 1000, + 968, + 1115, + 1109, + 1087, + 995, + 1124, + 1087, + 1094, + 1136, + 1009, + 966, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:47.985Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 84, + 232, + 226, + 230, + 230, + 226, + 229, + 214, + 229, + 229, + 220, + 229, + 229, + 250, + 244, + 247, + 266, + 268, + 268, + 255, + 248, + 247, + 242, + 244, + 248, + 250, + 238, + 242, + 259, + 256, + 259, + 263, + 255, + 241, + 257, + 250, + 237, + 240, + 243, + 253, + 257, + 253, + 258, + 261, + 254, + 254, + 256, + 258, + 240, + 240, + 230, + 223, + 231, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:47.418Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 8203, + 12546, + 11741, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:51.902Z', + }), + }), + 'timestamp': '2021-08-25T14:16:41.758Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + '0', + '1', + '2', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'enabled': dict({ + 'type': 'array', + 'value': list([ + '0', + '1', + ]), + }), + }), + 'timestamp': '2021-08-25T03:29:46.864Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'standby', + }), + }), + 'timestamp': '2021-08-25T03:29:47.643Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.power.production', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.634Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.power.production', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': False, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.547Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.551Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'charging', + 'oneTimeCharge', + 'schedule', + 'sensors', + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'status': dict({ + 'type': 'string', + 'value': 'on', + }), + }), + 'timestamp': '2021-08-25T03:29:47.650Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.circulation.pump', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.642Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors.temperature.main', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 63, + }), + }), + 'timestamp': '2021-08-25T15:13:19.598Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.main', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.circulation.pump', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:47.641Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': False, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/deactivate', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 23, + }), + }), + 'timestamp': '2021-08-25T03:29:47.549Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.charging.level', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'bottom': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'middle': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'top': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T03:29:47.603Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging.level', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.standard', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.728Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standard', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'holiday', + ]), + 'deviceId': '0', + 'feature': 'heating.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.880Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'eco', + 'holiday', + 'standard', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '05:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '05:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.871Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.middle', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.710Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.middle', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:47.508Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTargetTemperature': dict({ + 'isExecutable': True, + 'name': 'setTargetTemperature', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'efficientLowerBorder': 10, + 'efficientUpperBorder': 60, + 'max': 60, + 'min': 10, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.temperature.main', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 58, + }), + }), + 'timestamp': '2021-08-25T03:29:46.819Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.oneTimeCharge', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:47.607Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.total', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 22, + 33, + 32, + 34, + 32, + 32, + 32, + 32, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:37.198Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 805, + 1000, + 968, + 4623, + 6819, + 7578, + 8101, + 9255, + 7815, + 4532, + 3249, + 1345, + 966, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:42.956Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 84, + 232, + 226, + 230, + 230, + 226, + 229, + 214, + 229, + 229, + 220, + 229, + 253, + 794, + 1050, + 883, + 1419, + 1349, + 1543, + 1837, + 1842, + 1135, + 1595, + 1922, + 1836, + 1757, + 1331, + 1929, + 2938, + 1903, + 2175, + 1931, + 2125, + 2118, + 2042, + 1575, + 1588, + 1958, + 1840, + 1473, + 963, + 815, + 911, + 690, + 696, + 883, + 691, + 672, + 389, + 240, + 230, + 223, + 231, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-23T01:22:41.933Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 39149, + 44834, + 49007, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:38.203Z', + }), + }), + 'timestamp': '2021-08-25T14:16:41.785Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.total', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0.modulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'unit': dict({ + 'type': 'string', + 'value': 'percent', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'percent', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T14:16:46.499Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.modulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'total', + ]), + 'deviceId': '0', + 'feature': 'heating.power.consumption', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.555Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'outside', + ]), + 'deviceId': '0', + 'feature': 'heating.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.564Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'collector', + 'dhw', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'level', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.charging', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T14:16:41.453Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:47.524Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'charging', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'main', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'standby', + }), + }), + 'timestamp': '2021-08-25T03:29:47.645Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.695Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'level', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.830Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'standard', + 'standby', + 'ventilation', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.978Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors.temperature.outlet', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'error', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + }), + 'timestamp': '2021-08-25T03:29:47.637Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'time', + ]), + 'deviceId': '0', + 'feature': 'heating.device', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.device.time.offset', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 96, + }), + }), + 'timestamp': '2021-08-25T03:29:47.575Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time.offset', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.562Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.frostprotection', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:46.900Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.frostprotection', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature.dhw', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.633Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.4, + }), + }), + 'timestamp': '2021-08-25T03:29:46.910Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.975Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T03:29:47.538Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.external', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors.temperature.hotWaterStorage', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 58.6, + }), + }), + 'timestamp': '2021-08-25T15:02:49.557Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 25.5, + }), + }), + 'timestamp': '2021-08-25T11:03:00.515Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes', + }), + ]), + }), + 'entry': dict({ + 'data': dict({ + 'client_id': '**REDACTED**', + 'heating_type': 'auto', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'vicare', + 'entry_id': '1234', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'ViCare', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 10b7861ef78..72fb8d0d0b6 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -1,132 +1,123 @@ """Test the ViCare config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import ENTRY_CONFIG, MOCK_MAC +from . import MOCK_MAC, MODULE from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +VALID_CONFIG = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", +} + +DHCP_INFO = dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, +) + + +async def test_user_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert len(result["errors"]) == 0 + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + # test PyViCareInvalidCredentialsError with patch( - "homeassistant.components.vicare.config_flow.vicare_login", - return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_invalid_login(hass: HomeAssistant) -> None: - """Test a flow with an invalid Vicare login.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", side_effect=PyViCareInvalidCredentialsError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() -async def test_form_dhcp(hass: HomeAssistant) -> None: +async def test_form_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: """Test we can setup from dhcp.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() async def test_dhcp_single_instance_allowed(hass: HomeAssistant) -> None: """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -135,12 +126,12 @@ async def test_user_input_single_instance_allowed(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="ViCare", - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/vicare/test_diagnostics.py b/tests/components/vicare/test_diagnostics.py new file mode 100644 index 00000000000..815b39545a9 --- /dev/null +++ b/tests/components/vicare/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test ViCare diagnostics.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_vicare_gas_boiler: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_vicare_gas_boiler + ) + + assert diag == snapshot diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index a9491d4d3b6..91ea5b3e439 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -250,6 +250,7 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: }, name="VLC", slug="vlc", + uuid="1234", ) result = await hass.config_entries.flow.async_init( @@ -286,7 +287,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=entry_data, name="VLC", slug="vlc"), + data=HassioServiceInfo(config=entry_data, name="VLC", slug="vlc", uuid="1234"), ) await hass.async_block_till_done() @@ -330,6 +331,7 @@ async def test_hassio_errors( }, name="VLC", slug="vlc", + uuid="1234", ), ) await hass.async_block_till_done() diff --git a/tests/components/voice_assistant/__init__.py b/tests/components/voice_assistant/__init__.py deleted file mode 100644 index 6838f353c4b..00000000000 --- a/tests/components/voice_assistant/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Voice Assistant integration.""" diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py deleted file mode 100644 index 149d896dcf6..00000000000 --- a/tests/components/voice_assistant/test_websocket.py +++ /dev/null @@ -1,556 +0,0 @@ -"""Websocket tests for Voice Assistant integration.""" -import asyncio -from collections.abc import AsyncIterable -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components import stt, tts -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_setup_component - -from tests.common import MockModule, mock_integration, mock_platform -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mock_get_cache_files, - mock_init_cache_dir, -) -from tests.typing import WebSocketGenerator - -_TRANSCRIPT = "test transcript" - - -class MockSttProvider(stt.Provider): - """Mock STT provider.""" - - def __init__(self, hass: HomeAssistant, text: str) -> None: - """Init test provider.""" - self.hass = hass - self.text = text - - @property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return ["en-US"] - - @property - def supported_formats(self) -> list[stt.AudioFormats]: - """Return a list of supported formats.""" - return [stt.AudioFormats.WAV] - - @property - def supported_codecs(self) -> list[stt.AudioCodecs]: - """Return a list of supported codecs.""" - return [stt.AudioCodecs.PCM] - - @property - def supported_bit_rates(self) -> list[stt.AudioBitRates]: - """Return a list of supported bitrates.""" - return [stt.AudioBitRates.BITRATE_16] - - @property - def supported_sample_rates(self) -> list[stt.AudioSampleRates]: - """Return a list of supported samplerates.""" - return [stt.AudioSampleRates.SAMPLERATE_16000] - - @property - def supported_channels(self) -> list[stt.AudioChannels]: - """Return a list of supported channels.""" - return [stt.AudioChannels.CHANNEL_MONO] - - async def async_process_audio_stream( - self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] - ) -> stt.SpeechResult: - """Process an audio stream.""" - return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) - - -class MockSTT: - """A mock STT platform.""" - - async def async_get_engine( - self, - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: - """Set up a mock speech component.""" - return MockSttProvider(hass, _TRANSCRIPT) - - -class MockTTSProvider(tts.Provider): - """Mock TTS provider.""" - - name = "Test" - - @property - def default_language(self) -> str: - """Return the default language.""" - return "en" - - @property - def supported_languages(self) -> list[str]: - """Return list of supported languages.""" - return ["en"] - - @property - def supported_options(self) -> list[str]: - """Return list of supported options like voice, emotions.""" - return ["voice", "age"] - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None - ) -> tts.TtsAudioType: - """Load TTS dat.""" - return ("mp3", b"") - - -class MockTTS: - """A mock TTS platform.""" - - PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA - - async def async_get_engine( - self, - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: - """Set up a mock speech component.""" - return MockTTSProvider() - - -@pytest.fixture(autouse=True) -async def init_components( - hass: HomeAssistant, - mock_get_cache_files, # noqa: F811 - mock_init_cache_dir, # noqa: F811 -): - """Initialize relevant components with empty configs.""" - mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.tts", MockTTS()) - mock_platform(hass, "test.stt", MockSTT()) - - 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", {}) - assert await async_setup_component(hass, "voice_assistant", {}) - - -async def test_text_only_pipeline( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test events from a pipeline run with text input (no STT/TTS).""" - client = await hass_ws_client(hass) - - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "intent", - "end_stage": "intent", - "input": {"text": "Are the lights on?"}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == {} - - -async def test_audio_pipeline( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion -) -> None: - """Test events from a pipeline run with audio input/output.""" - client = await hass_ws_client(hass) - - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "stt", - "end_stage": "tts", - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == snapshot - - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - - # text to speech - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == snapshot - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == {} - - -async def test_intent_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test partial pipeline run with conversation agent timeout.""" - client = await hass_ws_client(hass) - - async def sleepy_converse(*args, **kwargs): - await asyncio.sleep(3600) - - with patch( - "homeassistant.components.conversation.async_converse", - new=sleepy_converse, - ): - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "intent", - "end_stage": "intent", - "input": {"text": "Are the lights on?"}, - "timeout": 0.00001, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - - # timeout error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"] == snapshot - - -async def test_text_pipeline_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test text-only pipeline run with immediate timeout.""" - client = await hass_ws_client(hass) - - async def sleepy_run(*args, **kwargs): - await asyncio.sleep(3600) - - with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", - new=sleepy_run, - ): - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "intent", - "end_stage": "intent", - "input": {"text": "Are the lights on?"}, - "timeout": 0.0001, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # timeout error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"] == snapshot - - -async def test_intent_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test text-only pipeline run with conversation agent error.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.conversation.async_converse", - new=MagicMock(return_value=RuntimeError), - ): - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "intent", - "end_stage": "intent", - "input": {"text": "Are the lights on?"}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # intent start - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - - # intent error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "intent-failed" - - -async def test_audio_pipeline_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test audio pipeline run with immediate timeout.""" - client = await hass_ws_client(hass) - - async def sleepy_run(*args, **kwargs): - await asyncio.sleep(3600) - - with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", - new=sleepy_run, - ): - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "stt", - "end_stage": "tts", - "timeout": 0.0001, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # timeout error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "timeout" - - -async def test_stt_provider_missing( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test events from a pipeline run with a non-existent STT provider.""" - with patch( - "homeassistant.components.stt.async_get_provider", - new=MagicMock(return_value=None), - ): - client = await hass_ws_client(hass) - - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "stt", - "end_stage": "tts", - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - # stt error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "stt-provider-missing" - - -async def test_stt_stream_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test events from a pipeline run with a non-existent STT provider.""" - with patch( - "tests.components.voice_assistant.test_websocket.MockSttProvider.async_process_audio_stream", - new=MagicMock(side_effect=RuntimeError), - ): - client = await hass_ws_client(hass) - - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "stt", - "end_stage": "tts", - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - # stt error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "stt-stream-failed" - - -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text to speech error.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - new=MagicMock(return_value=RuntimeError), - ): - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - - -async def test_invalid_stage_order( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components -) -> None: - """Test pipeline run with invalid stage order.""" - client = await hass_ws_client(hass) - - await client.send_json( - { - "id": 5, - "type": "voice_assistant/run", - "start_stage": "tts", - "end_stage": "stt", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert not msg["success"] diff --git a/tests/components/voip/__init__.py b/tests/components/voip/__init__.py new file mode 100644 index 00000000000..97389d74c2b --- /dev/null +++ b/tests/components/voip/__init__.py @@ -0,0 +1 @@ +"""Tests for the Voice over IP integration.""" diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py new file mode 100644 index 00000000000..0bdcc55bfd8 --- /dev/null +++ b/tests/components/voip/conftest.py @@ -0,0 +1,79 @@ +"""Test helpers for VoIP integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from voip_utils import CallInfo + +from homeassistant.components.voip import DOMAIN +from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def setup_voip(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up VoIP integration.""" + with patch( + "homeassistant.components.voip._create_sip_server", + return_value=(Mock(), AsyncMock()), + ): + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + yield + + +@pytest.fixture +async def voip_devices(hass: HomeAssistant, setup_voip: None) -> VoIPDevices: + """Get VoIP devices object from a configured instance.""" + return hass.data[DOMAIN].devices + + +@pytest.fixture +def call_info() -> CallInfo: + """Fake call info.""" + return CallInfo( + caller_ip="192.168.1.210", + caller_sip_port=5060, + caller_rtp_port=5004, + server_ip="192.168.1.10", + headers={ + "via": "SIP/2.0/UDP 192.168.1.210:5060;branch=z9hG4bK912387041;rport", + "from": ";tag=1836983217", + "to": "", + "call-id": "860888843-5060-9@BJC.BGI.B.CBA", + "cseq": "80 INVITE", + "contact": "", + "max-forwards": "70", + "user-agent": "Grandstream HT801 1.0.17.5", + "supported": "replaces, path, timer, eventlist", + "allow": "INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE", + "content-type": "application/sdp", + "accept": "application/sdp, application/dtmf-relay", + "content-length": "480", + }, + ) + + +@pytest.fixture +async def voip_device( + hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo +) -> VoIPDevice: + """Get a VoIP device fixture.""" + device = voip_devices.async_get_or_create(call_info) + # to make sure all platforms are set up + await hass.async_block_till_done() + return device diff --git a/tests/components/voip/snapshots/test_init.ambr b/tests/components/voip/snapshots/test_init.ambr new file mode 100644 index 00000000000..319276ffcd7 --- /dev/null +++ b/tests/components/voip/snapshots/test_init.ambr @@ -0,0 +1,13 @@ +# serializer version: 1 +# name: test_user_management + list([ + dict({ + 'id': 'system-users', + 'name': 'Users', + 'policy': dict({ + 'entities': True, + }), + 'system_generated': True, + }), + ]) +# --- diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py new file mode 100644 index 00000000000..794d307ee01 --- /dev/null +++ b/tests/components/voip/test_binary_sensor.py @@ -0,0 +1,25 @@ +"""Test VoIP binary sensor devices.""" +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_call_in_progress( + hass: HomeAssistant, + config_entry: ConfigEntry, + voip_device: VoIPDevice, +) -> None: + """Test call in progress.""" + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + assert state is not None + assert state.state == "off" + + voip_device.set_is_active(True) + + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + assert state.state == "on" + + voip_device.set_is_active(False) + + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + assert state.state == "off" diff --git a/tests/components/voip/test_config_flow.py b/tests/components/voip/test_config_flow.py new file mode 100644 index 00000000000..f7b3595699c --- /dev/null +++ b/tests/components/voip/test_config_flow.py @@ -0,0 +1,81 @@ +"""Test VoIP config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import voip +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_user(hass: HomeAssistant) -> None: + """Test user form config flow.""" + + result = await hass.config_entries.flow.async_init( + voip.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert not result["errors"] + + with patch( + "homeassistant.components.voip.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_instance( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Test that only one instance can be created.""" + result = await hass.config_entries.flow.async_init( + voip.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=voip.DOMAIN, + data={}, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + assert config_entry.options == {} + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5060} + + # Manual + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5061} diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py new file mode 100644 index 00000000000..af5b176281e --- /dev/null +++ b/tests/components/voip/test_devices.py @@ -0,0 +1,72 @@ +"""Test VoIP devices.""" + +from __future__ import annotations + +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 + + +async def test_device_registry_info( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: DeviceRegistry, +) -> None: + """Test info in device registry.""" + voip_device = voip_devices.async_get_or_create(call_info) + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device is not None + assert device.name == call_info.caller_ip + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" + + # Test we update the device if the fw updates + call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0" + voip_device = voip_devices.async_get_or_create(call_info) + + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device.sw_version == "2.0.0.0" + + +async def test_device_registry_info_from_unknown_phone( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: DeviceRegistry, +) -> None: + """Test info in device registry from unknown phone.""" + call_info.headers["user-agent"] = "Unknown" + voip_device = voip_devices.async_get_or_create(call_info) + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device.manufacturer is None + assert device.model == "Unknown" + assert device.sw_version is None + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + voip_device: VoIPDevice, + voip_devices: VoIPDevices, + device_registry: DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + assert voip_device.voip_id in voip_devices.devices + assert hass.states.get("switch.192_168_1_210_allow_calls") is not None + + device_registry.async_remove_device(voip_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get("switch.192_168_1_210_allow_calls") is None + assert voip_device.voip_id not in voip_devices.devices diff --git a/tests/components/voip/test_init.py b/tests/components/voip/test_init.py new file mode 100644 index 00000000000..edc55685597 --- /dev/null +++ b/tests/components/voip/test_init.py @@ -0,0 +1,32 @@ +"""Test VoIP init.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + + +async def test_unload_entry( + hass: HomeAssistant, + config_entry, + setup_voip, +) -> None: + """Test adding/removing VoIP.""" + assert await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_user_management( + hass: HomeAssistant, config_entry, setup_voip, snapshot: SnapshotAssertion +) -> None: + """Test creating and removing voip user.""" + user = await hass.auth.async_get_user(config_entry.data["user"]) + assert user is not None + assert user.is_active + assert user.system_generated + assert not user.is_admin + assert user.name == "Voice over IP" + assert user.groups == snapshot + assert len(user.credentials) == 0 + assert len(user.refresh_tokens) == 0 + + await hass.config_entries.async_remove(config_entry.entry_id) + + assert await hass.auth.async_get_user(user.id) is None diff --git a/tests/components/voip/test_select.py b/tests/components/voip/test_select.py new file mode 100644 index 00000000000..19c3202576a --- /dev/null +++ b/tests/components/voip/test_select.py @@ -0,0 +1,19 @@ +"""Test VoIP select.""" +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_pipeline_select( + hass: HomeAssistant, + config_entry: ConfigEntry, + voip_device: VoIPDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + state = hass.states.get("select.192_168_1_210_assist_pipeline") + assert state is not None + assert state.state == "preferred" diff --git a/tests/components/voip/test_sip.py b/tests/components/voip/test_sip.py new file mode 100644 index 00000000000..975b8f326d9 --- /dev/null +++ b/tests/components/voip/test_sip.py @@ -0,0 +1,55 @@ +"""Test SIP server.""" +import socket + +import pytest + +from homeassistant import config_entries +from homeassistant.components import voip +from homeassistant.core import HomeAssistant + + +async def test_create_sip_server(hass: HomeAssistant, socket_enabled) -> None: + """Tests starting/stopping SIP server.""" + result = await hass.config_entries.flow.async_init( + voip.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + entry = result["result"] + await hass.async_block_till_done() + + with pytest.raises(OSError), socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) as sock: + # Server should have the port + sock.bind(("127.0.0.1", 5060)) + + # Configure different port + result = await hass.config_entries.options.async_init( + entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061}, + ) + await hass.async_block_till_done() + + # Server should be stopped now on 5060 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 5060)) + + with pytest.raises(OSError), socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) as sock: + # Server should now have the new port + sock.bind(("127.0.0.1", 5061)) + + # Shut down + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + # Server should be stopped + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 5061)) diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py new file mode 100644 index 00000000000..eb8fcfa2220 --- /dev/null +++ b/tests/components/voip/test_switch.py @@ -0,0 +1,52 @@ +"""Test VoIP switch devices.""" +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_allow_call( + hass: HomeAssistant, + config_entry: ConfigEntry, + voip_device: VoIPDevice, +) -> None: + """Test allow call.""" + assert not voip_device.async_allow_call(hass) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state is not None + assert state.state == "off" + + await hass.config_entries.async_reload(config_entry.entry_id) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "off" + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.192_168_1_210_allow_calls"}, + blocking=True, + ) + + assert voip_device.async_allow_call(hass) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "on" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "on" + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.192_168_1_210_allow_calls"}, + blocking=True, + ) + + assert not voip_device.async_allow_call(hass) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "off" diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py new file mode 100644 index 00000000000..bd9a3587a9a --- /dev/null +++ b/tests/components/voip/test_voip.py @@ -0,0 +1,319 @@ +"""Test VoIP protocol.""" +import asyncio +import time +from unittest.mock import AsyncMock, Mock, patch + +import async_timeout +import pytest + +from homeassistant.components import assist_pipeline, voip +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.core import Context, HomeAssistant +from homeassistant.setup import async_setup_component + +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit +_MEDIA_ID = "12345" + + +async def test_pipeline( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that pipeline function is called from RTP protocol.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + # Used to test that audio queue is cleared before pipeline starts + bad_chunk = bytes([1, 2, 3, 4]) + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + assert _chunk != bad_chunk + + # Test empty data + event_callback( + assist_pipeline.PipelineEvent( + type="not-used", + data={}, + ) + ) + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + assert media_source_id == _MEDIA_ID + + return ("mp3", b"") + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + listening_tone_enabled=False, + processing_tone_enabled=False, + error_tone_enabled=False, + ) + rtp_protocol.transport = Mock() + + # Ensure audio queue is cleared before pipeline starts + rtp_protocol._audio_queue.put_nowait(bad_chunk) + + def send_audio(*args, **kwargs): + # Test finished successfully + done.set() + + rtp_protocol.send_audio = Mock(side_effect=send_audio) + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # Wait for mock pipeline to exhaust the audio stream + async with async_timeout.timeout(1): + await done.wait() + + +async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None: + """Test timeout during pipeline run.""" + assert await async_setup_component(hass, "voip", {}) + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + await asyncio.sleep(10) + + with patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._wait_for_speech", + return_value=True, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + pipeline_timeout=0.001, + listening_tone_enabled=False, + processing_tone_enabled=False, + error_tone_enabled=False, + ) + transport = Mock(spec=["close"]) + rtp_protocol.connection_made(transport) + + # Closing the transport will cause the test to succeed + transport.close.side_effect = done.set + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # Wait for mock pipeline to time out + async with async_timeout.timeout(1): + await done.wait() + + +async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None: + """Test timeout in STT stream during pipeline run.""" + assert await async_setup_component(hass, "voip", {}) + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + async for _chunk in stt_stream: + # Iterate over stream + pass + + with patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + audio_timeout=0.001, + listening_tone_enabled=False, + processing_tone_enabled=False, + error_tone_enabled=False, + ) + transport = Mock(spec=["close"]) + rtp_protocol.connection_made(transport) + + # Closing the transport will cause the test to succeed + transport.close.side_effect = done.set + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # Wait for mock pipeline to time out + async with async_timeout.timeout(1): + await done.wait() + + +async def test_tts_timeout( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will time out based on its length.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + tone_bytes = bytes([1, 2, 3, 4]) + + def send_audio(audio_bytes, **kwargs): + if audio_bytes == tone_bytes: + # Not TTS + return + + # Block here to force a timeout in _send_tts + time.sleep(2) + + async def async_send_audio(audio_bytes, **kwargs): + if audio_bytes == tone_bytes: + # Not TTS + return + + # Block here to force a timeout in _send_tts + await asyncio.sleep(2) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should time out immediately + return ("raw", bytes(0)) + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + tts_extra_timeout=0.001, + listening_tone_enabled=True, + processing_tone_enabled=True, + error_tone_enabled=True, + ) + rtp_protocol._tone_bytes = tone_bytes + rtp_protocol._processing_bytes = tone_bytes + rtp_protocol._error_bytes = tone_bytes + rtp_protocol.transport = Mock() + rtp_protocol.send_audio = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(asyncio.TimeoutError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # Wait for mock pipeline to exhaust the audio stream + async with async_timeout.timeout(1): + await done.wait() diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index ed20e01cb2c..b995a066c51 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -22,10 +22,7 @@ from homeassistant.components.wallbox.const import ( CHARGER_SERIAL_NUMBER_KEY, CHARGER_SOFTWARE_KEY, CHARGER_STATUS_ID_KEY, - CONF_STATION, - DOMAIN, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from .const import ERROR, STATUS, TTL, USER_ID @@ -88,22 +85,9 @@ authorisation_response_unauthorised = json.loads( ) ) -entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test_username", - CONF_PASSWORD: "test_password", - CONF_STATION: "12345", - }, - entry_id="testEntry", -) - -async def setup_integration(hass: HomeAssistant) -> None: +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" - - entry.add_to_hass(hass) - with requests_mock.Mocker() as mock_request: mock_request.get( "https://user-api.wall-box.com/users/signin", @@ -121,15 +105,14 @@ async def setup_integration(hass: HomeAssistant) -> None: status_code=HTTPStatus.OK, ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() -async def setup_integration_connection_error(hass: HomeAssistant) -> None: +async def setup_integration_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class setup with a connection error.""" - with requests_mock.Mocker() as mock_request: mock_request.get( "https://user-api.wall-box.com/users/signin", @@ -147,13 +130,13 @@ async def setup_integration_connection_error(hass: HomeAssistant) -> None: status_code=HTTPStatus.FORBIDDEN, ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() -async def setup_integration_read_only(hass: HomeAssistant) -> None: +async def setup_integration_read_only( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class setup for read only.""" with requests_mock.Mocker() as mock_request: @@ -173,13 +156,13 @@ async def setup_integration_read_only(hass: HomeAssistant) -> None: status_code=HTTPStatus.FORBIDDEN, ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() -async def setup_integration_platform_not_ready(hass: HomeAssistant) -> None: +async def setup_integration_platform_not_ready( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class setup for read only.""" with requests_mock.Mocker() as mock_request: @@ -199,7 +182,5 @@ async def setup_integration_platform_not_ready(hass: HomeAssistant) -> None: status_code=HTTPStatus.NOT_FOUND, ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py new file mode 100644 index 00000000000..4677dc95e6f --- /dev/null +++ b/tests/components/wallbox/conftest.py @@ -0,0 +1,24 @@ +"""Test fixtures for the Wallbox integration.""" +import pytest + +from homeassistant.components.wallbox.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test_username", + CONF_PASSWORD: "test_password", + CONF_STATION: "12345", + }, + entry_id="testEntry", + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index bd9e51adda7..bf0ab95e522 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -21,10 +21,11 @@ from homeassistant.core import HomeAssistant from . import ( authorisation_response, authorisation_response_unauthorised, - entry, setup_integration, ) +from tests.common import MockConfigEntry + test_response = json.loads( json.dumps( { @@ -139,9 +140,9 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: assert result2["data"]["station"] == "12345" -async def test_form_reauth(hass: HomeAssistant) -> None: +async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test we handle reauth flow.""" - await setup_integration(hass) + await setup_integration(hass, entry) assert entry.state == config_entries.ConfigEntryState.LOADED with requests_mock.Mocker() as mock_request: @@ -179,9 +180,9 @@ async def test_form_reauth(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_form_reauth_invalid(hass: HomeAssistant) -> None: +async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test we handle reauth invalid flow.""" - await setup_integration(hass) + await setup_integration(hass, entry) assert entry.state == config_entries.ConfigEntryState.LOADED with requests_mock.Mocker() as mock_request: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index a0db03b6c43..2afe2d245a8 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -3,45 +3,51 @@ import json import requests_mock -from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY +from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import ( - DOMAIN, authorisation_response, - entry, setup_integration, setup_integration_connection_error, setup_integration_read_only, test_response, ) +from tests.common import MockConfigEntry -async def test_wallbox_setup_unload_entry(hass: HomeAssistant) -> None: + +async def test_wallbox_setup_unload_entry( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test Wallbox Unload.""" - await setup_integration(hass) + await setup_integration(hass, entry) assert entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state == ConfigEntryState.NOT_LOADED -async def test_wallbox_unload_entry_connection_error(hass: HomeAssistant) -> None: +async def test_wallbox_unload_entry_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test Wallbox Unload Connection Error.""" - await setup_integration_connection_error(hass) + await setup_integration_connection_error(hass, entry) assert entry.state == ConfigEntryState.SETUP_ERROR assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state == ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_invalid_auth(hass: HomeAssistant) -> None: +async def test_wallbox_refresh_failed_invalid_auth( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test Wallbox setup with authentication error.""" - await setup_integration(hass) + await setup_integration(hass, entry) assert entry.state == ConfigEntryState.LOADED with requests_mock.Mocker() as mock_request: @@ -64,10 +70,12 @@ async def test_wallbox_refresh_failed_invalid_auth(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_connection_error(hass: HomeAssistant) -> None: +async def test_wallbox_refresh_failed_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test Wallbox setup with connection error.""" - await setup_integration(hass) + await setup_integration(hass, entry) assert entry.state == ConfigEntryState.LOADED with requests_mock.Mocker() as mock_request: @@ -90,10 +98,12 @@ async def test_wallbox_refresh_failed_connection_error(hass: HomeAssistant) -> N assert entry.state == ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_read_only(hass: HomeAssistant) -> None: +async def test_wallbox_refresh_failed_read_only( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test Wallbox setup for read-only user.""" - await setup_integration_read_only(hass) + await setup_integration_read_only(hass, entry) assert entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index bf0daa5c828..f812d27d8c2 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -11,18 +11,19 @@ from homeassistant.core import HomeAssistant from . import ( authorisation_response, - entry, setup_integration, setup_integration_platform_not_ready, setup_integration_read_only, ) from .const import MOCK_LOCK_ENTITY_ID +from tests.common import MockConfigEntry -async def test_wallbox_lock_class(hass: HomeAssistant) -> None: + +async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox lock class.""" - await setup_integration(hass) + await setup_integration(hass, entry) state = hass.states.get(MOCK_LOCK_ENTITY_ID) assert state @@ -61,10 +62,12 @@ async def test_wallbox_lock_class(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_lock_class_connection_error(hass: HomeAssistant) -> None: +async def test_wallbox_lock_class_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox lock class connection error.""" - await setup_integration(hass) + await setup_integration(hass, entry) with requests_mock.Mocker() as mock_request: mock_request.get( @@ -100,10 +103,12 @@ async def test_wallbox_lock_class_connection_error(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_lock_class_authentication_error(hass: HomeAssistant) -> None: +async def test_wallbox_lock_class_authentication_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox lock not loaded on authentication error.""" - await setup_integration_read_only(hass) + await setup_integration_read_only(hass, entry) state = hass.states.get(MOCK_LOCK_ENTITY_ID) @@ -112,10 +117,12 @@ async def test_wallbox_lock_class_authentication_error(hass: HomeAssistant) -> N await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_lock_class_platform_not_ready(hass: HomeAssistant) -> None: +async def test_wallbox_lock_class_platform_not_ready( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox lock not loaded on authentication error.""" - await setup_integration_platform_not_ready(hass) + await setup_integration_platform_not_ready(hass, entry) state = hass.states.get(MOCK_LOCK_ENTITY_ID) diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index eb11c4fc0cc..8f3e6274220 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -11,17 +11,20 @@ from homeassistant.core import HomeAssistant from . import ( authorisation_response, - entry, setup_integration, setup_integration_platform_not_ready, ) from .const import MOCK_NUMBER_ENTITY_ID +from tests.common import MockConfigEntry -async def test_wallbox_number_class(hass: HomeAssistant) -> None: + +async def test_wallbox_number_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class.""" - await setup_integration(hass) + await setup_integration(hass, entry) with requests_mock.Mocker() as mock_request: mock_request.get( @@ -47,10 +50,12 @@ async def test_wallbox_number_class(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_number_class_connection_error(hass: HomeAssistant) -> None: +async def test_wallbox_number_class_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class.""" - await setup_integration(hass) + await setup_integration(hass, entry) with requests_mock.Mocker() as mock_request: mock_request.get( @@ -77,10 +82,12 @@ async def test_wallbox_number_class_connection_error(hass: HomeAssistant) -> Non await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_number_class_platform_not_ready(hass: HomeAssistant) -> None: +async def test_wallbox_number_class_platform_not_ready( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox lock not loaded on authentication error.""" - await setup_integration_platform_not_ready(hass) + await setup_integration_platform_not_ready(hass, entry) state = hass.states.get(MOCK_NUMBER_ENTITY_ID) diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index d8a3926fd4c..a6bda688997 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -2,18 +2,22 @@ from homeassistant.const import CONF_ICON, CONF_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant -from . import entry, setup_integration +from . import setup_integration from .const import ( MOCK_SENSOR_CHARGING_POWER_ID, MOCK_SENSOR_CHARGING_SPEED_ID, MOCK_SENSOR_MAX_AVAILABLE_POWER, ) +from tests.common import MockConfigEntry -async def test_wallbox_sensor_class(hass: HomeAssistant) -> None: + +async def test_wallbox_sensor_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox sensor class.""" - await setup_integration(hass) + await setup_integration(hass, entry) state = hass.states.get(MOCK_SENSOR_CHARGING_POWER_ID) assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 588eea04513..2b4d49b5af9 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,14 +10,18 @@ from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from . import authorisation_response, entry, setup_integration +from . import authorisation_response, setup_integration from .const import MOCK_SWITCH_ENTITY_ID +from tests.common import MockConfigEntry -async def test_wallbox_switch_class(hass: HomeAssistant) -> None: + +async def test_wallbox_switch_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox switch class.""" - await setup_integration(hass) + await setup_integration(hass, entry) state = hass.states.get(MOCK_SWITCH_ENTITY_ID) assert state @@ -56,10 +60,12 @@ async def test_wallbox_switch_class(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_switch_class_connection_error(hass: HomeAssistant) -> None: +async def test_wallbox_switch_class_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox switch class connection error.""" - await setup_integration(hass) + await setup_integration(hass, entry) with requests_mock.Mocker() as mock_request: mock_request.get( @@ -95,10 +101,12 @@ async def test_wallbox_switch_class_connection_error(hass: HomeAssistant) -> Non await hass.config_entries.async_unload(entry.entry_id) -async def test_wallbox_switch_class_authentication_error(hass: HomeAssistant) -> None: +async def test_wallbox_switch_class_authentication_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test wallbox switch class connection error.""" - await setup_integration(hass) + await setup_integration(hass, entry) with requests_mock.Mocker() as mock_request: mock_request.get( diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index febe2fd7df8..c7a2e61ba4c 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -31,7 +31,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/waze_travel_time/const.py b/tests/components/waze_travel_time/const.py index f56e8c5892e..314a24d23e4 100644 --- a/tests/components/waze_travel_time/const.py +++ b/tests/components/waze_travel_time/const.py @@ -11,3 +11,9 @@ MOCK_CONFIG = { CONF_DESTINATION: "location2", CONF_REGION: "US", } + +CONFIG_FLOW_USER_INPUT = { + CONF_ORIGIN: "location1", + CONF_DESTINATION: "location2", + CONF_REGION: "us", +} diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index d58f8d9a34d..f1fd3041d46 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -21,7 +21,7 @@ from homeassistant.components.waze_travel_time.const import ( from homeassistant.const import CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant -from .const import MOCK_CONFIG +from .const import CONFIG_FLOW_USER_INPUT, MOCK_CONFIG from tests.common import MockConfigEntry @@ -37,7 +37,7 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_CONFIG, + CONFIG_FLOW_USER_INPUT, ) await hass.async_block_till_done() @@ -116,7 +116,7 @@ async def test_dupe(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_CONFIG, + CONFIG_FLOW_USER_INPUT, ) await hass.async_block_till_done() @@ -131,7 +131,7 @@ async def test_dupe(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_CONFIG, + CONFIG_FLOW_USER_INPUT, ) await hass.async_block_till_done() @@ -150,7 +150,7 @@ async def test_invalid_config_entry( assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_CONFIG, + CONFIG_FLOW_USER_INPUT, ) assert result2["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 04ae04a044c..5d7928124dd 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -18,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() @@ -30,7 +31,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - states = await hass.async_add_executor_job(get_significant_states, hass, now) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 56ccbddca56..c0853aa49d9 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -140,7 +140,9 @@ async def test_webhook_head(hass: HomeAssistant, mock_client) -> None: """Handle webhook.""" hooks.append(args) - webhook.async_register(hass, "test", "Test hook", webhook_id, handle) + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, allowed_methods=["HEAD"] + ) resp = await mock_client.head(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK @@ -149,6 +151,58 @@ async def test_webhook_head(hass: HomeAssistant, mock_client) -> None: assert hooks[0][1] == webhook_id assert hooks[0][2].method == "HEAD" + # Test that status is HTTPStatus.OK even when HEAD is not allowed. + webhook.async_unregister(hass, webhook_id) + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, allowed_methods=["PUT"] + ) + resp = await mock_client.head(f"/api/webhook/{webhook_id}") + assert resp.status == HTTPStatus.OK + assert len(hooks) == 1 # Should not have been called + + +async def test_webhook_get(hass, mock_client): + """Test sending a get request to a webhook.""" + hooks = [] + webhook_id = webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, allowed_methods=["GET"] + ) + + resp = await mock_client.get(f"/api/webhook/{webhook_id}") + assert resp.status == HTTPStatus.OK + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2].method == "GET" + + # Test that status is HTTPStatus.METHOD_NOT_ALLOWED even when GET is not allowed. + webhook.async_unregister(hass, webhook_id) + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, allowed_methods=["PUT"] + ) + resp = await mock_client.get(f"/api/webhook/{webhook_id}") + assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED + assert len(hooks) == 1 # Should not have been called + + +async def test_webhook_not_allowed_method(hass): + """Test that an exception is raised if an unsupported method is used.""" + webhook_id = webhook.async_generate_id() + + async def handle(*args): + pass + + with pytest.raises(ValueError): + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, allowed_methods=["PATCH"] + ) + async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None: """Test posting a webhook with local only.""" @@ -192,7 +246,15 @@ async def test_listing_webhook( client = await hass_ws_client(hass, hass_access_token) webhook.async_register(hass, "test", "Test hook", "my-id", None) - webhook.async_register(hass, "test", "Test hook", "my-2", None, local_only=True) + webhook.async_register( + hass, + "test", + "Test hook", + "my-2", + None, + local_only=True, + allowed_methods=["GET"], + ) await client.send_json({"id": 5, "type": "webhook/list"}) @@ -205,12 +267,14 @@ async def test_listing_webhook( "domain": "test", "name": "Test hook", "local_only": False, + "allowed_methods": ["POST", "PUT"], }, { "webhook_id": "my-2", "domain": "test", "name": "Test hook", "local_only": True, + "allowed_methods": ["GET"], }, ] diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 1912a962cd0..c2788deca30 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -1,4 +1,5 @@ """The tests for the webhook automation trigger.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -77,7 +78,11 @@ async def test_webhook_post( "automation", { "automation": { - "trigger": {"platform": "webhook", "webhook_id": "post_webhook"}, + "trigger": { + "platform": "webhook", + "webhook_id": "post_webhook", + "local_only": True, + }, "action": { "event": "test_success", "event_data_template": {"hello": "yo {{ trigger.data.hello }}"}, @@ -95,6 +100,64 @@ async def test_webhook_post( assert len(events) == 1 assert events[0].data["hello"] == "yo world" + # Request from remote IP + with patch( + "homeassistant.components.webhook.ip_address", + return_value=ip_address("123.123.123.123"), + ): + await client.post("/api/webhook/post_webhook", data={"hello": "world"}) + # No hook received + await hass.async_block_till_done() + assert len(events) == 1 + + +async def test_webhook_allowed_methods_internet(hass, hass_client_no_auth): + """Test the webhook obeys allowed_methods and local_only options.""" + events = [] + + @callback + def store_event(event): + """Help store events.""" + events.append(event) + + hass.bus.async_listen("test_success", store_event) + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "webhook", + "webhook_id": "post_webhook", + "allowed_methods": "PUT", + # Enable after 2023.4.0: "local_only": False, + }, + "action": { + "event": "test_success", + "event_data_template": {"hello": "yo {{ trigger.data.hello }}"}, + }, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + await client.post("/api/webhook/post_webhook", data={"hello": "world"}) + await hass.async_block_till_done() + + assert len(events) == 0 + + # Request from remote IP + with patch( + "homeassistant.components.webhook.ip_address", + return_value=ip_address("123.123.123.123"), + ): + await client.put("/api/webhook/post_webhook", data={"hello": "world"}) + await hass.async_block_till_done() + assert len(events) == 1 + async def test_webhook_query( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index c01701f7d53..7e3e0b2dce8 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -107,6 +107,28 @@ async def test_static_config_with_invalid_host(hass: HomeAssistant) -> None: assert not setup_success +async def test_static_with_upnp_failure( + hass: HomeAssistant, pywemo_device: pywemo.WeMoDevice +) -> None: + """Device that fails to get state is not added.""" + pywemo_device.get_state.side_effect = pywemo.exceptions.ActionException("Failed") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DISCOVERY: False, + CONF_STATIC: [f"{MOCK_HOST}:{MOCK_PORT}"], + }, + }, + ) + await hass.async_block_till_done() + entity_reg = er.async_get(hass) + entity_entries = list(entity_reg.entities.values()) + assert len(entity_entries) == 0 + pywemo_device.get_state.assert_called_once() + + async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: """Verify that discovery dispatches devices to the platform for setup.""" diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 52bb87817f2..91aa207d60f 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -31,7 +31,7 @@ async def test_full_user_flow( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -71,7 +71,7 @@ async def test_full_flow_with_error( ) assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_whois.side_effect = throw result2 = await hass.config_entries.flow.async_configure( @@ -80,7 +80,7 @@ async def test_full_flow_with_error( ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("step_id") == SOURCE_USER + assert result2.get("step_id") == "user" assert result2.get("errors") == {"base": reason} assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index afde266e8b9..e5c246dc95e 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -20,7 +20,6 @@ from homeassistant import data_entry_flow import homeassistant.components.api as api from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN import homeassistant.components.webhook as webhook -from homeassistant.components.withings import async_unload_entry from homeassistant.components.withings.common import ( ConfigEntryWithingsApi, DataManager, @@ -290,7 +289,7 @@ class ComponentFactory: config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) for config_entry in config_entries: - await async_unload_entry(self._hass, config_entry) + await config_entry.async_unload(self._hass) await self._hass.async_block_till_done() diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 80c8f8d5841..005a63397d9 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -8,22 +8,37 @@ from homeassistant.components.workday.const import ( DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, + DOMAIN, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry async def init_integration( hass: HomeAssistant, config: dict[str, Any], -) -> None: - """Set up the Workday integration in Home Assistant.""" + entry_id: str = "1", + source: str = SOURCE_USER, +) -> MockConfigEntry: + """Set up the Scrape integration in Home Assistant.""" - await async_setup_component( - hass, "binary_sensor", {"binary_sensor": {"platform": "workday", **config}} + config_entry = MockConfigEntry( + domain=DOMAIN, + source=source, + data={}, + options=config, + entry_id=entry_id, ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return config_entry + TEST_CONFIG_WITH_PROVINCE = { "name": DEFAULT_NAME, diff --git a/tests/components/workday/conftest.py b/tests/components/workday/conftest.py new file mode 100644 index 00000000000..a9e4dd2ffd0 --- /dev/null +++ b/tests/components/workday/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Workday integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.workday.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 89c98a0c67e..71dd23c19a3 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -79,6 +79,34 @@ async def test_setup( } +async def test_setup_from_import( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "workday", + "country": "DE", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Workday Sensor", + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + } + + async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py new file mode 100644 index 00000000000..7e28471c78c --- /dev/null +++ b/tests/components/workday/test_config_flow.py @@ -0,0 +1,488 @@ +"""Test the Workday config flow.""" +from __future__ import annotations + +import pytest + +from homeassistant import config_entries +from homeassistant.components.workday.const import ( + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + DOMAIN, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import init_integration + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "SE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_COUNTRY: "DE", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Workday Sensor" + assert result["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday Sensor 2", + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Workday Sensor 2" + assert result2["options"] == { + "name": "Workday Sensor 2", + "country": "DE", + "province": "BW", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + } + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday sensor 2", + CONF_COUNTRY: "DE", + CONF_EXCLUDES: ["sat", "sun", "holiday"], + CONF_OFFSET: 0, + CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow_province_no_conflict(hass: HomeAssistant) -> None: + """Test import of yaml with province.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Workday sensor 2", + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + CONF_EXCLUDES: ["sat", "sun", "holiday"], + CONF_OFFSET: 0, + CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we get the form in options.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": "BW", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": "BW", + } + + +async def test_form_incorrect_dates(hass: HomeAssistant) -> None: + """Test errors in setup entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-xx-12"], + CONF_REMOVE_HOLIDAYS: [], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + assert result3["errors"] == {"add_holidays": "add_holiday_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12"], + CONF_REMOVE_HOLIDAYS: ["Does not exist"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["errors"] == {"remove_holidays": "remove_holiday_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12"], + CONF_REMOVE_HOLIDAYS: ["Weihnachtstag"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12"], + "remove_holidays": ["Weihnachtstag"], + "province": None, + } + + +async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: + """Test errors in options.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-xx-12"], + "remove_holidays": [], + "province": "BW", + }, + ) + + assert result2["errors"] == {"add_holidays": "add_holiday_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12"], + "remove_holidays": ["Does not exist"], + "province": "BW", + }, + ) + + assert result2["errors"] == {"remove_holidays": "remove_holiday_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12"], + "remove_holidays": ["Weihnachtstag"], + "province": "BW", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12"], + "remove_holidays": ["Weihnachtstag"], + "province": "BW", + } + + +async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: + """Test errors in options for duplicates.""" + + await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + entry_id="1", + ) + entry2 = await init_integration( + hass, + { + "name": "Workday Sensor2", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2023-03-28"], + "remove_holidays": [], + "province": None, + }, + entry_id="2", + ) + + result = await hass.config_entries.options.async_init(entry2.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0.0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": "none", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"} diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py new file mode 100644 index 00000000000..047409b5078 --- /dev/null +++ b/tests/components/workday/test_init.py @@ -0,0 +1,51 @@ +"""Test Workday component setup process.""" +from __future__ import annotations + +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import UTC + +from . import TEST_CONFIG_EXAMPLE_1, TEST_CONFIG_WITH_PROVINCE, init_integration + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + entry = await init_integration(hass, TEST_CONFIG_EXAMPLE_1) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + +async def test_update_options( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test options update and config entry is reloaded.""" + freezer.move_to(datetime(2023, 4, 12, 12, tzinfo=UTC)) # Monday + + entry = await init_integration(hass, TEST_CONFIG_WITH_PROVINCE) + assert entry.state == config_entries.ConfigEntryState.LOADED + assert entry.update_listeners is not None + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" + + new_options = TEST_CONFIG_WITH_PROVINCE.copy() + new_options["add_holidays"] = ["2023-04-12"] + + hass.config_entries.async_update_entry(entry, options=new_options) + await hass.async_block_till_done() + + entry_check = hass.config_entries.async_get_entry("1") + assert entry_check.state == config_entries.ConfigEntryState.LOADED + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py new file mode 100644 index 00000000000..d48b908f26b --- /dev/null +++ b/tests/components/wyoming/__init__.py @@ -0,0 +1,71 @@ +"""Tests for the Wyoming integration.""" +from wyoming.info import AsrModel, AsrProgram, Attribution, Info, TtsProgram, TtsVoice + +TEST_ATTR = Attribution(name="Test", url="http://www.test.com") +STT_INFO = Info( + asr=[ + AsrProgram( + name="Test ASR", + installed=True, + attribution=TEST_ATTR, + models=[ + AsrModel( + name="Test Model", + installed=True, + attribution=TEST_ATTR, + languages=["en-US"], + ) + ], + ) + ] +) +TTS_INFO = Info( + tts=[ + TtsProgram( + name="Test TTS", + installed=True, + attribution=TEST_ATTR, + voices=[ + TtsVoice( + name="Test Voice", + installed=True, + attribution=TEST_ATTR, + languages=["en-US"], + ) + ], + ) + ] +) +EMPTY_INFO = Info() + + +class MockAsyncTcpClient: + """Mock AsyncTcpClient.""" + + def __init__(self, responses) -> None: + """Initialize.""" + self.host = None + self.port = None + self.written = [] + self.responses = responses + + async def write_event(self, event): + """Send.""" + self.written.append(event) + + async def read_event(self): + """Receive.""" + return self.responses.pop(0) + + async def __aenter__(self): + """Enter.""" + return self + + async def __aexit__(self, exc_type, exc, tb): + """Exit.""" + + def __call__(self, host, port): + """Call.""" + self.host = host + self.port = port + return self diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py new file mode 100644 index 00000000000..0dd9041a0d5 --- /dev/null +++ b/tests/components/wyoming/conftest.py @@ -0,0 +1,71 @@ +"""Common fixtures for the Wyoming tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import STT_INFO, TTS_INFO + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.wyoming.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def stt_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test ASR", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def tts_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test TTS", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): + """Initialize Wyoming STT.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=STT_INFO, + ): + await hass.config_entries.async_setup(stt_config_entry.entry_id) + + +@pytest.fixture +async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): + """Initialize Wyoming TTS.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=TTS_INFO, + ): + await hass.config_entries.async_setup(tts_config_entry.entry_id) diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..d4220a39724 --- /dev/null +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -0,0 +1,123 @@ +# serializer version: 1 +# name: test_hassio_addon_discovery + FlowResultSnapshot({ + 'context': dict({ + 'source': 'hassio', + 'unique_id': '1234', + }), + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'hassio', + 'title': 'Piper', + 'unique_id': '1234', + 'version': 1, + }), + 'title': 'Piper', + 'type': , + 'version': 1, + }) +# --- +# name: test_hassio_addon_discovery[info0] + FlowResultSnapshot({ + 'context': dict({ + 'configuration_url': 'homeassistant://hassio/addon/mock_piper/info', + 'source': 'hassio', + 'title_placeholders': dict({ + 'name': 'Piper', + }), + 'unique_id': '1234', + }), + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'hassio', + 'title': 'Piper', + 'unique_id': '1234', + 'version': 1, + }), + 'title': 'Piper', + 'type': , + 'version': 1, + }) +# --- +# name: test_hassio_addon_discovery[info1] + FlowResultSnapshot({ + 'context': dict({ + 'configuration_url': 'homeassistant://hassio/addon/mock_piper/info', + 'source': 'hassio', + 'title_placeholders': dict({ + 'name': 'Piper', + }), + 'unique_id': '1234', + }), + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': 'mock-piper', + 'port': 10200, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'hassio', + 'title': 'Piper', + 'unique_id': '1234', + 'version': 1, + }), + 'title': 'Piper', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/snapshots/test_data.ambr b/tests/components/wyoming/snapshots/test_data.ambr new file mode 100644 index 00000000000..c47e40a0dc4 --- /dev/null +++ b/tests/components/wyoming/snapshots/test_data.ambr @@ -0,0 +1,11 @@ +# serializer version: 1 +# name: test_load_info + list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'describe', + }), + ]) +# --- diff --git a/tests/components/wyoming/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr new file mode 100644 index 00000000000..08fe6a1ef8e --- /dev/null +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -0,0 +1,42 @@ +# serializer version: 1 +# name: test_streaming_audio + list([ + dict({ + 'data': dict({ + 'channels': 1, + 'rate': 16000, + 'timestamp': None, + 'width': 2, + }), + 'payload': None, + 'type': 'audio-start', + }), + dict({ + 'data': dict({ + 'channels': 1, + 'rate': 16000, + 'timestamp': None, + 'width': 2, + }), + 'payload': 'chunk1', + 'type': 'audio-chunk', + }), + dict({ + 'data': dict({ + 'channels': 1, + 'rate': 16000, + 'timestamp': None, + 'width': 2, + }), + 'payload': 'chunk2', + 'type': 'audio-chunk', + }), + dict({ + 'data': dict({ + 'timestamp': None, + }), + 'payload': None, + 'type': 'audio-stop', + }), + ]) +# --- diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr new file mode 100644 index 00000000000..eb0b33c3276 --- /dev/null +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_get_tts_audio + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- +# name: test_get_tts_audio_raw + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py new file mode 100644 index 00000000000..896d3748ebd --- /dev/null +++ b/tests/components/wyoming/test_config_flow.py @@ -0,0 +1,216 @@ +"""Test the Wyoming config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from wyoming.info import Info + +from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import EMPTY_INFO, STT_INFO, TTS_INFO + +from tests.common import MockConfigEntry + +ADDON_DISCOVERY = HassioServiceInfo( + config={ + "addon": "Piper", + "uri": "tcp://mock-piper:10200", + }, + name="Piper", + slug="mock_piper", + uuid="1234", +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form_stt(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=STT_INFO, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test ASR" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_tts(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=TTS_INFO, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test TTS" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_no_supported_services(hass: HomeAssistant) -> None: + """Test we handle no supported services error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=EMPTY_INFO, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "no_services" + + +@pytest.mark.parametrize("info", [STT_INFO, TTS_INFO]) +async def test_hassio_addon_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, + info: Info, +) -> None: + """Test config flow initiated by Supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_DISCOVERY, + context={"source": config_entries.SOURCE_HASSIO}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "hassio_confirm" + assert result.get("description_placeholders") == {"addon": "Piper"} + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=info, + ) as mock_wyoming: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_wyoming.mock_calls) == 1 + + +async def test_hassio_addon_already_configured(hass: HomeAssistant) -> None: + """Test we abort discovery if the add-on is already configured.""" + MockConfigEntry( + domain=DOMAIN, + data={"host": "mock-piper", "port": "10200"}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_DISCOVERY, + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_hassio_addon_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_DISCOVERY, + context={"source": config_entries.SOURCE_HASSIO}, + ) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: + """Test we handle no supported services error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_DISCOVERY, + context={"source": config_entries.SOURCE_HASSIO}, + ) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=EMPTY_INFO, + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py new file mode 100644 index 00000000000..0cb878c39c1 --- /dev/null +++ b/tests/components/wyoming/test_data.py @@ -0,0 +1,40 @@ +"""Test tts.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant.components.wyoming.data import load_wyoming_info +from homeassistant.core import HomeAssistant + +from . import STT_INFO, MockAsyncTcpClient + + +async def test_load_info(hass: HomeAssistant, snapshot) -> None: + """Test loading info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ) as mock_client: + info = await load_wyoming_info("localhost", 1234) + + assert info == STT_INFO + assert mock_client.written == snapshot + + +async def test_load_info_oserror(hass: HomeAssistant) -> None: + """Test loading info and error raising.""" + mock_client = MockAsyncTcpClient([STT_INFO.event()]) + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + mock_client, + ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): + info = await load_wyoming_info( + "localhost", + 1234, + retries=0, + retry_wait=0, + timeout=0.001, + ) + + assert info is None diff --git a/tests/components/wyoming/test_init.py b/tests/components/wyoming/test_init.py new file mode 100644 index 00000000000..85539f5a164 --- /dev/null +++ b/tests/components/wyoming/test_init.py @@ -0,0 +1,23 @@ +"""Test init.""" +from unittest.mock import patch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_cannot_connect( + hass: HomeAssistant, stt_config_entry: ConfigEntry +) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=None, + ): + assert not await hass.config_entries.async_setup(stt_config_entry.entry_id) + + +async def test_unload( + hass: HomeAssistant, stt_config_entry: ConfigEntry, init_wyoming_stt +) -> None: + """Test unload.""" + assert await hass.config_entries.async_unload(stt_config_entry.entry_id) diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py new file mode 100644 index 00000000000..021419f3a5e --- /dev/null +++ b/tests/components/wyoming/test_stt.py @@ -0,0 +1,87 @@ +"""Test stt.""" +from __future__ import annotations + +from unittest.mock import patch + +from wyoming.asr import Transcript + +from homeassistant.components import stt +from homeassistant.core import HomeAssistant + +from . import MockAsyncTcpClient + + +async def test_support(hass: HomeAssistant, init_wyoming_stt) -> None: + """Test supported properties.""" + state = hass.states.get("stt.test_asr") + assert state is not None + + entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") + assert entity is not None + + assert entity.supported_languages == ["en-US"] + assert entity.supported_formats == [stt.AudioFormats.WAV] + assert entity.supported_codecs == [stt.AudioCodecs.PCM] + assert entity.supported_bit_rates == [stt.AudioBitRates.BITRATE_16] + assert entity.supported_sample_rates == [stt.AudioSampleRates.SAMPLERATE_16000] + assert entity.supported_channels == [stt.AudioChannels.CHANNEL_MONO] + + +async def test_streaming_audio(hass: HomeAssistant, init_wyoming_stt, snapshot) -> None: + """Test streaming audio.""" + entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") + assert entity is not None + + async def audio_stream(): + yield "chunk1" + yield "chunk2" + + with patch( + "homeassistant.components.wyoming.stt.AsyncTcpClient", + MockAsyncTcpClient([Transcript(text="Hello world").event()]), + ) as mock_client: + result = await entity.async_process_audio_stream(None, audio_stream()) + + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "Hello world" + assert mock_client.written == snapshot + + +async def test_streaming_audio_connection_lost( + hass: HomeAssistant, init_wyoming_stt +) -> None: + """Test streaming audio and losing connection.""" + entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") + assert entity is not None + + async def audio_stream(): + yield "chunk1" + + with patch( + "homeassistant.components.wyoming.stt.AsyncTcpClient", + MockAsyncTcpClient([None]), + ): + result = await entity.async_process_audio_stream(None, audio_stream()) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +async def test_streaming_audio_oserror(hass: HomeAssistant, init_wyoming_stt) -> None: + """Test streaming audio and error raising.""" + entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") + assert entity is not None + + async def audio_stream(): + yield "chunk1" + + mock_client = MockAsyncTcpClient([Transcript(text="Hello world").event()]) + + with patch( + "homeassistant.components.wyoming.stt.AsyncTcpClient", + mock_client, + ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): + result = await entity.async_process_audio_stream(None, audio_stream()) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py new file mode 100644 index 00000000000..f2a10710c26 --- /dev/null +++ b/tests/components/wyoming/test_tts.py @@ -0,0 +1,139 @@ +"""Test tts.""" +from __future__ import annotations + +import io +from unittest.mock import patch +import wave + +import pytest +from wyoming.audio import AudioChunk, AudioStop + +from homeassistant.components import tts +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +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, +) + + +async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: + """Test supported properties.""" + state = hass.states.get("tts.test_tts") + assert state is not None + + entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts") + assert entity is not None + + assert entity.supported_languages == ["en-US"] + assert entity.supported_options == [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE] + voices = entity.async_get_supported_voices("en-US") + assert len(voices) == 1 + assert voices[0].name == "Test Voice" + assert voices[0].voice_id == "Test Voice" + assert not entity.async_get_supported_voices("de-DE") + + +async def test_get_tts_audio(hass: HomeAssistant, init_wyoming_tts, snapshot) -> None: + """Test get audio.""" + audio = bytes(100) + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), + ) + + assert extension == "wav" + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + assert wav_file.readframes(wav_file.getnframes()) == audio + + assert mock_client.written == snapshot + + +async def test_get_tts_audio_raw( + hass: HomeAssistant, init_wyoming_tts, snapshot +) -> None: + """Test get raw audio.""" + audio = bytes(100) + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + extension, data = await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + options={tts.ATTR_AUDIO_OUTPUT: "raw"}, + ), + ) + + assert extension == "raw" + assert data == audio + assert mock_client.written == snapshot + + +async def test_get_tts_audio_connection_lost( + hass: HomeAssistant, init_wyoming_tts +) -> None: + """Test streaming audio and losing connection.""" + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([None]), + ), pytest.raises(HomeAssistantError): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), + ) + + +async def test_get_tts_audio_audio_oserror( + hass: HomeAssistant, init_wyoming_tts +) -> None: + """Test get audio and error raising.""" + audio = bytes(100) + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + mock_client = MockAsyncTcpClient(audio_events) + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + mock_client, + ), patch.object( + mock_client, "read_event", side_effect=OSError("Boom!") + ), pytest.raises( + HomeAssistantError + ): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, "Hello world", "tts.test_tts", hass.config.language + ), + ) diff --git a/tests/components/youless/test_config_flows.py b/tests/components/youless/test_config_flows.py index 08f38f8eb2c..6512103cde0 100644 --- a/tests/components/youless/test_config_flows.py +++ b/tests/components/youless/test_config_flows.py @@ -27,7 +27,7 @@ async def test_full_flow(hass: HomeAssistant) -> None: assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {} - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_youless = _get_mock_youless_api( initialize={"homes": [{"id": 1, "name": "myhome"}]} @@ -54,7 +54,7 @@ async def test_not_found(hass: HomeAssistant) -> None: assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {} - assert result.get("step_id") == SOURCE_USER + assert result.get("step_id") == "user" mock_youless = _get_mock_youless_api(initialize=URLError("")) with patch( diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index cae67f8d768..d3f3bf9b654 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -167,7 +167,9 @@ def find_entity_ids(domain, zha_device, hass): def async_find_group_entity_id(hass, domain, group): """Find the group entity id under test.""" - entity_id = f"{domain}.fakemanufacturer_fakemodel_{group.name.lower().replace(' ','_')}_zha_group_0x{group.group_id:04x}" + entity_id = ( + f"{domain}.fakemanufacturer_fakemodel_{group.name.lower().replace(' ', '_')}" + ) entity_ids = hass.states.async_entity_ids(domain) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 784e6bae731..271108496b2 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,7 +1,8 @@ """Test configuration for the ZHA component.""" +from collections.abc import Callable import itertools import time -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest import zigpy @@ -12,7 +13,7 @@ from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.device import zigpy.group import zigpy.profiles -from zigpy.state import State +import zigpy.quirks import zigpy.types import zigpy.zdo.types as zdo_t @@ -43,31 +44,82 @@ def globally_load_quirks(): zhaquirks.setup() +class _FakeApp(ControllerApplication): + async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor): + pass + + async def connect(self): + pass + + async def disconnect(self): + pass + + async def force_remove(self, dev: zigpy.device.Device): + pass + + async def load_network_info(self, *, load_devices: bool = False): + pass + + async def permit_ncp(self, time_s: int = 60): + pass + + async def permit_with_key( + self, node: zigpy.types.EUI64, code: bytes, time_s: int = 60 + ): + pass + + async def reset_network_info(self): + pass + + async def send_packet(self, packet: zigpy.types.ZigbeePacket): + pass + + async def start_network(self): + pass + + async def write_network_info(self): + pass + + async def request( + self, + device: zigpy.device.Device, + profile: zigpy.types.uint16_t, + cluster: zigpy.types.uint16_t, + src_ep: zigpy.types.uint8_t, + dst_ep: zigpy.types.uint8_t, + sequence: zigpy.types.uint8_t, + data: bytes, + *, + expect_reply: bool = True, + use_ieee: bool = False, + extended_timeout: bool = False, + ): + pass + + @pytest.fixture def zigpy_app_controller(): """Zigpy ApplicationController fixture.""" - app = MagicMock(spec_set=ControllerApplication) - app.startup = AsyncMock() - app.shutdown = AsyncMock() - groups = zigpy.group.Groups(app) - groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True) - app.configure_mock(groups=groups) - type(app).ieee = PropertyMock() - app.ieee.return_value = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") - type(app).nwk = PropertyMock(return_value=zigpy.types.NWK(0x0000)) - type(app).devices = PropertyMock(return_value={}) - type(app).backups = zigpy.backups.BackupManager(app) - type(app).topology = zigpy.topology.Topology(app) + app = _FakeApp( + { + zigpy.config.CONF_DATABASE: None, + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}, + } + ) - state = State() - state.node_info.ieee = app.ieee.return_value - state.network_info.extended_pan_id = app.ieee.return_value - state.network_info.pan_id = 0x1234 - state.network_info.channel = 15 - state.network_info.network_key.key = zigpy.types.KeyData(range(16)) - type(app).state = PropertyMock(return_value=state) + app.groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True) - return app + app.state.node_info.nwk = 0x0000 + app.state.node_info.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") + app.state.network_info.pan_id = 0x1234 + app.state.network_info.extended_pan_id = app.state.node_info.ieee + app.state.network_info.channel = 15 + app.state.network_info.network_key.key = zigpy.types.KeyData(range(16)) + + with patch("zigpy.device.Device.request"), patch.object( + app, "permit", autospec=True + ), patch.object(app, "permit_with_key", autospec=True): + yield app @pytest.fixture(name="config_entry") @@ -121,19 +173,19 @@ def setup_zha(hass, config_entry, zigpy_app_controller): @pytest.fixture -def channel(): - """Channel mock factory fixture.""" +def cluster_handler(): + """ClusterHandler mock factory fixture.""" - def channel(name: str, cluster_id: int, endpoint_id: int = 1): + def cluster_handler(name: str, cluster_id: int, endpoint_id: int = 1): ch = MagicMock() ch.name = name - ch.generic_id = f"channel_0x{cluster_id:04x}" + ch.generic_id = f"cluster_handler_0x{cluster_id:04x}" ch.id = f"{endpoint_id}:0x{cluster_id:04x}" ch.async_configure = AsyncMock() ch.async_initialize = AsyncMock() return ch - return channel + return cluster_handler @pytest.fixture @@ -149,6 +201,7 @@ def zigpy_device_mock(zigpy_app_controller): nwk=0xB79C, patch_cluster=True, quirk=None, + attributes=None, ): """Make a fake device using the specified cluster classes.""" device = zigpy.device.Device( @@ -162,8 +215,8 @@ def zigpy_device_mock(zigpy_app_controller): for epid, ep in endpoints.items(): endpoint = device.add_endpoint(epid) endpoint.device_type = ep[SIG_EP_TYPE] - endpoint.profile_id = ep.get(SIG_EP_PROFILE) - endpoint.request = AsyncMock(return_value=[0]) + endpoint.profile_id = ep.get(SIG_EP_PROFILE, 0x0104) + endpoint.request = AsyncMock() for cluster_id in ep.get(SIG_EP_INPUT, []): endpoint.add_input_cluster(cluster_id) @@ -171,8 +224,13 @@ def zigpy_device_mock(zigpy_app_controller): for cluster_id in ep.get(SIG_EP_OUTPUT, []): endpoint.add_output_cluster(cluster_id) + device.status = zigpy.device.Status.ENDPOINTS_INIT + if quirk: device = quirk(zigpy_app_controller, device.ieee, device.nwk, device) + else: + # Allow zigpy to apply quirks if we don't pass one explicitly + device = zigpy.quirks.get_device(device) if patch_cluster: for endpoint in (ep for epid, ep in device.endpoints.items() if epid): @@ -182,6 +240,15 @@ def zigpy_device_mock(zigpy_app_controller): ): common.patch_cluster(cluster) + if attributes is not None: + for ep_id, clusters in attributes.items(): + for cluster_name, attrs in clusters.items(): + cluster = getattr(device.endpoints[ep_id], cluster_name) + + for name, value in attrs.items(): + attr_id = cluster.find_attribute(name).id + cluster._attr_cache[attr_id] = value + return device return _mock_dev @@ -228,7 +295,9 @@ def zha_device_joined_restored(request): @pytest.fixture -def zha_device_mock(hass, zigpy_device_mock): +def zha_device_mock( + hass, zigpy_device_mock +) -> Callable[..., zha_core_device.ZHADevice]: """Return a ZHA Device factory.""" def _zha_device( @@ -238,7 +307,7 @@ def zha_device_mock(hass, zigpy_device_mock): model="mock model", node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", patch_cluster=True, - ): + ) -> zha_core_device.ZHADevice: if endpoints is None: endpoints = { 1: { diff --git a/tests/components/zha/data.py b/tests/components/zha/data.py index 024a5e75fbc..eb135c7e8fe 100644 --- a/tests/components/zha/data.py +++ b/tests/components/zha/data.py @@ -4,8 +4,9 @@ BASE_CUSTOM_CONFIGURATION = { "schemas": { "zha_options": [ { - "type": "integer", + "type": "float", "valueMin": 0, + "valueMax": 6553.6, "name": "default_light_transition", "optional": True, "default": 0, @@ -74,8 +75,9 @@ CONFIG_WITH_ALARM_OPTIONS = { "schemas": { "zha_options": [ { - "type": "integer", + "type": "float", "valueMin": 0, + "valueMax": 6553.6, "name": "default_light_transition", "optional": True, "default": 0, diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 59daf2179b6..85f85cc0437 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -1,5 +1,8 @@ """Test ZHA API.""" -from unittest.mock import patch +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call, patch import pytest import zigpy.backups @@ -10,6 +13,9 @@ from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType from homeassistant.core import HomeAssistant +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + @pytest.fixture(autouse=True) def required_platform_only(): @@ -29,7 +35,7 @@ async def test_async_get_network_settings_active( async def test_async_get_network_settings_inactive( - hass: HomeAssistant, setup_zha, zigpy_app_controller + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication ) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -44,17 +50,22 @@ async def test_async_get_network_settings_inactive( with patch( "bellows.zigbee.application.ControllerApplication.__new__", return_value=zigpy_app_controller, - ): + ), patch.object( + zigpy_app_controller, "_load_db", wraps=zigpy_app_controller._load_db + ) as mock_load_db, patch.object( + zigpy_app_controller, + "start_network", + wraps=zigpy_app_controller.start_network, + ) as mock_start_network: settings = await api.async_get_network_settings(hass) - assert len(zigpy_app_controller._load_db.mock_calls) == 1 - assert len(zigpy_app_controller.start_network.mock_calls) == 0 - + assert len(mock_load_db.mock_calls) == 1 + assert len(mock_start_network.mock_calls) == 0 assert settings.network_info.channel == 20 async def test_async_get_network_settings_missing( - hass: HomeAssistant, setup_zha, zigpy_app_controller + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication ) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -95,3 +106,38 @@ async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> No radio_path = api.async_get_radio_path(hass) assert radio_path == "/dev/ttyUSB0" + + +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: + await api.async_change_channel(hass, 20) + + assert mock_move_network_to_channel.mock_calls == [call(20)] + + +async def test_change_channel_auto( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel automatically using an energy scan.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel, patch.object( + zigpy_app_controller, + "energy_scan", + autospec=True, + return_value={c: c for c in range(11, 26 + 1)}, + ), patch.object( + api, "pick_optimal_channel", autospec=True, return_value=25 + ): + await api.async_change_channel(hass, "auto") + + assert mock_move_network_to_channel.mock_calls == [call(25)] diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py index fbb25f1cbd3..e9c5a0a8e9c 100644 --- a/tests/components/zha/test_base.py +++ b/tests/components/zha/test_base.py @@ -1,9 +1,9 @@ -"""Test ZHA base channel module.""" +"""Test ZHA base cluster handlers module.""" -from homeassistant.components.zha.core.channels.base import parse_and_log_command +from homeassistant.components.zha.core.cluster_handlers import parse_and_log_command -from tests.components.zha.test_channels import ( # noqa: F401 - channel_pool, +from tests.components.zha.test_cluster_handlers import ( # noqa: F401 + endpoint, poll_control_ch, zigpy_coordinator_device, ) diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_cluster_handlers.py similarity index 63% rename from tests/components/zha/test_channels.py rename to tests/components/zha/test_cluster_handlers.py index b8542433e7c..c0c455542d3 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -1,20 +1,23 @@ -"""Test ZHA Core channels.""" +"""Test ZHA Core cluster handlers.""" import asyncio +from collections.abc import Callable import math from unittest import mock from unittest.mock import AsyncMock, patch import pytest import zigpy.endpoint +from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha import zigpy.types as t from zigpy.zcl import foundation import zigpy.zcl.clusters import zigpy.zdo.types as zdo_t -import homeassistant.components.zha.core.channels as zha_channels -import homeassistant.components.zha.core.channels.base as base_channels +import homeassistant.components.zha.core.cluster_handlers as cluster_handlers import homeassistant.components.zha.core.const as zha_const +from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.endpoint import Endpoint import homeassistant.components.zha.core.registries as registries from homeassistant.core import HomeAssistant @@ -65,20 +68,22 @@ def zigpy_coordinator_device(zigpy_device_mock): @pytest.fixture -def channel_pool(zigpy_coordinator_device): - """Endpoint Channels fixture.""" - ch_pool_mock = mock.MagicMock(spec_set=zha_channels.ChannelPool) - ch_pool_mock.endpoint.device.application.get_device.return_value = ( +def endpoint(zigpy_coordinator_device): + """Endpoint fixture.""" + endpoint_mock = mock.MagicMock(spec_set=Endpoint) + endpoint_mock.zigpy_endpoint.device.application.get_device.return_value = ( zigpy_coordinator_device ) - type(ch_pool_mock).skip_configuration = mock.PropertyMock(return_value=False) - ch_pool_mock.id = 1 - return ch_pool_mock + type(endpoint_mock.device).skip_configuration = mock.PropertyMock( + return_value=False + ) + endpoint_mock.id = 1 + return endpoint_mock @pytest.fixture -def poll_control_ch(channel_pool, zigpy_device_mock): - """Poll control channel fixture.""" +def poll_control_ch(endpoint, zigpy_device_mock): + """Poll control cluster handler fixture.""" cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id zigpy_dev = zigpy_device_mock( {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, @@ -88,8 +93,8 @@ def poll_control_ch(channel_pool, zigpy_device_mock): ) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] - channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(cluster_id) - return channel_class(cluster, channel_pool) + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get(cluster_id) + return cluster_handler_class(cluster, endpoint) @pytest.fixture @@ -236,10 +241,10 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): ), ], ) -async def test_in_channel_config( - cluster_id, bind_count, attrs, channel_pool, zigpy_device_mock, zha_gateway +async def test_in_cluster_handler_config( + cluster_id, bind_count, attrs, endpoint, zigpy_device_mock, zha_gateway ) -> None: - """Test ZHA core channel configuration for input clusters.""" + """Test ZHA core cluster handler configuration for input clusters.""" zigpy_dev = zigpy_device_mock( {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", @@ -248,12 +253,12 @@ async def test_in_channel_config( ) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] - channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base_channels.ZigbeeChannel + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, cluster_handlers.ClusterHandler ) - channel = channel_class(cluster, channel_pool) + cluster_handler = cluster_handler_class(cluster, endpoint) - await channel.async_configure() + await cluster_handler.async_configure() assert cluster.bind.call_count == bind_count assert cluster.configure_reporting.call_count == 0 @@ -299,10 +304,10 @@ async def test_in_channel_config( (0x0B04, 1), ], ) -async def test_out_channel_config( - cluster_id, bind_count, channel_pool, zigpy_device_mock, zha_gateway +async def test_out_cluster_handler_config( + cluster_id, bind_count, endpoint, zigpy_device_mock, zha_gateway ) -> None: - """Test ZHA core channel configuration for output clusters.""" + """Test ZHA core cluster handler configuration for output clusters.""" zigpy_dev = zigpy_device_mock( {1: {SIG_EP_OUTPUT: [cluster_id], SIG_EP_INPUT: [], SIG_EP_TYPE: 0x1234}}, "00:11:22:33:44:55:66:77", @@ -312,102 +317,109 @@ async def test_out_channel_config( cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True - channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base_channels.ZigbeeChannel + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, cluster_handlers.ClusterHandler ) - channel = channel_class(cluster, channel_pool) + cluster_handler = cluster_handler_class(cluster, endpoint) - await channel.async_configure() + await cluster_handler.async_configure() assert cluster.bind.call_count == bind_count assert cluster.configure_reporting.call_count == 0 -def test_channel_registry() -> None: - """Test ZIGBEE Channel Registry.""" - for cluster_id, channel in registries.ZIGBEE_CHANNEL_REGISTRY.items(): +def test_cluster_handler_registry() -> None: + """Test ZIGBEE cluster handler Registry.""" + for ( + cluster_id, + cluster_handler, + ) in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF - assert issubclass(channel, base_channels.ZigbeeChannel) + assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) -def test_epch_unclaimed_channels(channel) -> None: - """Test unclaimed channels.""" +def test_epch_unclaimed_cluster_handlers(cluster_handler) -> None: + """Test unclaimed cluster handlers.""" - ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) - ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) - ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + ch_1 = cluster_handler(zha_const.CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(zha_const.CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(zha_const.CLUSTER_HANDLER_COLOR, 768) - ep_channels = zha_channels.ChannelPool( - mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep + ep_cluster_handlers = Endpoint( + mock.MagicMock(spec_set=ZigpyEndpoint), mock.MagicMock(spec_set=ZHADevice) ) - all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} - with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True): - available = ep_channels.unclaimed_channels() + all_cluster_handlers = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict( + ep_cluster_handlers.all_cluster_handlers, all_cluster_handlers, clear=True + ): + available = ep_cluster_handlers.unclaimed_cluster_handlers() assert ch_1 in available assert ch_2 in available assert ch_3 in available - ep_channels.claimed_channels[ch_2.id] = ch_2 - available = ep_channels.unclaimed_channels() + ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] = ch_2 + available = ep_cluster_handlers.unclaimed_cluster_handlers() assert ch_1 in available assert ch_2 not in available assert ch_3 in available - ep_channels.claimed_channels[ch_1.id] = ch_1 - available = ep_channels.unclaimed_channels() + ep_cluster_handlers.claimed_cluster_handlers[ch_1.id] = ch_1 + available = ep_cluster_handlers.unclaimed_cluster_handlers() assert ch_1 not in available assert ch_2 not in available assert ch_3 in available - ep_channels.claimed_channels[ch_3.id] = ch_3 - available = ep_channels.unclaimed_channels() + ep_cluster_handlers.claimed_cluster_handlers[ch_3.id] = ch_3 + available = ep_cluster_handlers.unclaimed_cluster_handlers() assert ch_1 not in available assert ch_2 not in available assert ch_3 not in available -def test_epch_claim_channels(channel) -> None: - """Test channel claiming.""" +def test_epch_claim_cluster_handlers(cluster_handler) -> None: + """Test cluster handler claiming.""" - ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) - ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) - ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + ch_1 = cluster_handler(zha_const.CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(zha_const.CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(zha_const.CLUSTER_HANDLER_COLOR, 768) - ep_channels = zha_channels.ChannelPool( - mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep + ep_cluster_handlers = Endpoint( + mock.MagicMock(spec_set=ZigpyEndpoint), mock.MagicMock(spec_set=ZHADevice) ) - all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} - with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True): - assert ch_1.id not in ep_channels.claimed_channels - assert ch_2.id not in ep_channels.claimed_channels - assert ch_3.id not in ep_channels.claimed_channels + all_cluster_handlers = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict( + ep_cluster_handlers.all_cluster_handlers, all_cluster_handlers, clear=True + ): + assert ch_1.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_2.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_3.id not in ep_cluster_handlers.claimed_cluster_handlers - ep_channels.claim_channels([ch_2]) - assert ch_1.id not in ep_channels.claimed_channels - assert ch_2.id in ep_channels.claimed_channels - assert ep_channels.claimed_channels[ch_2.id] is ch_2 - assert ch_3.id not in ep_channels.claimed_channels + ep_cluster_handlers.claim_cluster_handlers([ch_2]) + assert ch_1.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_2.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] is ch_2 + assert ch_3.id not in ep_cluster_handlers.claimed_cluster_handlers - ep_channels.claim_channels([ch_3, ch_1]) - assert ch_1.id in ep_channels.claimed_channels - assert ep_channels.claimed_channels[ch_1.id] is ch_1 - assert ch_2.id in ep_channels.claimed_channels - assert ep_channels.claimed_channels[ch_2.id] is ch_2 - assert ch_3.id in ep_channels.claimed_channels - assert ep_channels.claimed_channels[ch_3.id] is ch_3 - assert "1:0x0300" in ep_channels.claimed_channels + ep_cluster_handlers.claim_cluster_handlers([ch_3, ch_1]) + assert ch_1.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_1.id] is ch_1 + assert ch_2.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] is ch_2 + assert ch_3.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_3.id] is ch_3 + assert "1:0x0300" in ep_cluster_handlers.claimed_cluster_handlers @mock.patch( - "homeassistant.components.zha.core.channels.ChannelPool.add_client_channels" + "homeassistant.components.zha.core.endpoint.Endpoint.add_client_cluster_handlers" ) @mock.patch( "homeassistant.components.zha.core.discovery.PROBE.discover_entities", mock.MagicMock(), ) -def test_ep_channels_all_channels(m1, zha_device_mock) -> None: - """Test EndpointChannels adding all channels.""" +def test_ep_all_cluster_handlers(m1, zha_device_mock: Callable[..., ZHADevice]) -> None: + """Test Endpoint adding all cluster handlers.""" zha_device = zha_device_mock( { 1: { @@ -422,43 +434,41 @@ def test_ep_channels_all_channels(m1, zha_device_mock) -> None: }, } ) - channels = zha_channels.Channels(zha_device) + assert "1:0x0000" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0001" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0006" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0008" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0000" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0001" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0006" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0008" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0000" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0001" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0006" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0008" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0000" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0006" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0008" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0300" in zha_device._endpoints[2].all_cluster_handlers - ep_channels = zha_channels.ChannelPool.new(channels, 1) - assert "1:0x0000" in ep_channels.all_channels - assert "1:0x0001" in ep_channels.all_channels - assert "1:0x0006" in ep_channels.all_channels - assert "1:0x0008" in ep_channels.all_channels - assert "1:0x0300" not in ep_channels.all_channels - assert "2:0x0000" not in ep_channels.all_channels - assert "2:0x0001" not in ep_channels.all_channels - assert "2:0x0006" not in ep_channels.all_channels - assert "2:0x0008" not in ep_channels.all_channels - assert "2:0x0300" not in ep_channels.all_channels - - channels = zha_channels.Channels(zha_device) - ep_channels = zha_channels.ChannelPool.new(channels, 2) - assert "1:0x0000" not in ep_channels.all_channels - assert "1:0x0001" not in ep_channels.all_channels - assert "1:0x0006" not in ep_channels.all_channels - assert "1:0x0008" not in ep_channels.all_channels - assert "1:0x0300" not in ep_channels.all_channels - assert "2:0x0000" in ep_channels.all_channels - assert "2:0x0001" in ep_channels.all_channels - assert "2:0x0006" in ep_channels.all_channels - assert "2:0x0008" in ep_channels.all_channels - assert "2:0x0300" in ep_channels.all_channels + zha_device.async_cleanup_handles() @mock.patch( - "homeassistant.components.zha.core.channels.ChannelPool.add_client_channels" + "homeassistant.components.zha.core.endpoint.Endpoint.add_client_cluster_handlers" ) @mock.patch( "homeassistant.components.zha.core.discovery.PROBE.discover_entities", mock.MagicMock(), ) -def test_channel_power_config(m1, zha_device_mock) -> None: - """Test that channels only get a single power channel.""" +def test_cluster_handler_power_config( + m1, zha_device_mock: Callable[..., ZHADevice] +) -> None: + """Test that cluster handlers only get a single power cluster handler.""" in_clusters = [0, 1, 6, 8] zha_device = zha_device_mock( { @@ -470,18 +480,18 @@ def test_channel_power_config(m1, zha_device_mock) -> None: }, } ) - channels = zha_channels.Channels.new(zha_device) - pools = {pool.id: pool for pool in channels.pools} - assert "1:0x0000" in pools[1].all_channels - assert "1:0x0001" in pools[1].all_channels - assert "1:0x0006" in pools[1].all_channels - assert "1:0x0008" in pools[1].all_channels - assert "1:0x0300" not in pools[1].all_channels - assert "2:0x0000" in pools[2].all_channels - assert "2:0x0001" not in pools[2].all_channels - assert "2:0x0006" in pools[2].all_channels - assert "2:0x0008" in pools[2].all_channels - assert "2:0x0300" in pools[2].all_channels + assert "1:0x0000" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0001" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0006" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0008" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0000" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0006" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0008" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0300" in zha_device._endpoints[2].all_cluster_handlers + + zha_device.async_cleanup_handles() zha_device = zha_device_mock( { @@ -489,46 +499,47 @@ def test_channel_power_config(m1, zha_device_mock) -> None: 2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}, } ) - channels = zha_channels.Channels.new(zha_device) - pools = {pool.id: pool for pool in channels.pools} - assert "1:0x0001" not in pools[1].all_channels - assert "2:0x0001" in pools[2].all_channels + assert "1:0x0001" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + + zha_device.async_cleanup_handles() zha_device = zha_device_mock( {2: {SIG_EP_INPUT: in_clusters, SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x0000}} ) - channels = zha_channels.Channels.new(zha_device) - pools = {pool.id: pool for pool in channels.pools} - assert "2:0x0001" in pools[2].all_channels + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + + zha_device.async_cleanup_handles() -async def test_ep_channels_configure(channel) -> None: - """Test unclaimed channels.""" +async def test_ep_cluster_handlers_configure(cluster_handler) -> None: + """Test unclaimed cluster handlers.""" - ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) - ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) - ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + ch_1 = cluster_handler(zha_const.CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(zha_const.CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(zha_const.CLUSTER_HANDLER_COLOR, 768) ch_3.async_configure = AsyncMock(side_effect=asyncio.TimeoutError) ch_3.async_initialize = AsyncMock(side_effect=asyncio.TimeoutError) - ch_4 = channel(zha_const.CHANNEL_ON_OFF, 6) - ch_5 = channel(zha_const.CHANNEL_LEVEL, 8) + ch_4 = cluster_handler(zha_const.CLUSTER_HANDLER_ON_OFF, 6) + ch_5 = cluster_handler(zha_const.CLUSTER_HANDLER_LEVEL, 8) ch_5.async_configure = AsyncMock(side_effect=asyncio.TimeoutError) ch_5.async_initialize = AsyncMock(side_effect=asyncio.TimeoutError) - channels = mock.MagicMock(spec_set=zha_channels.Channels) - type(channels).semaphore = mock.PropertyMock(return_value=asyncio.Semaphore(3)) - ep_channels = zha_channels.ChannelPool(channels, mock.sentinel.ep) + endpoint_mock = mock.MagicMock(spec_set=ZigpyEndpoint) + type(endpoint_mock).in_clusters = mock.PropertyMock(return_value={}) + type(endpoint_mock).out_clusters = mock.PropertyMock(return_value={}) + endpoint = Endpoint.new(endpoint_mock, mock.MagicMock(spec_set=ZHADevice)) claimed = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} - client_chans = {ch_4.id: ch_4, ch_5.id: ch_5} + client_handlers = {ch_4.id: ch_4, ch_5.id: ch_5} with mock.patch.dict( - ep_channels.claimed_channels, claimed, clear=True - ), mock.patch.dict(ep_channels.client_channels, client_chans, clear=True): - await ep_channels.async_configure() - await ep_channels.async_initialize(mock.sentinel.from_cache) + endpoint.claimed_cluster_handlers, claimed, clear=True + ), mock.patch.dict(endpoint.client_cluster_handlers, client_handlers, clear=True): + await endpoint.async_configure() + await endpoint.async_initialize(mock.sentinel.from_cache) - for ch in [*claimed.values(), *client_chans.values()]: + for ch in [*claimed.values(), *client_handlers.values()]: assert ch.async_initialize.call_count == 1 assert ch.async_initialize.await_count == 1 assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache @@ -540,7 +551,7 @@ async def test_ep_channels_configure(channel) -> None: async def test_poll_control_configure(poll_control_ch) -> None: - """Test poll control channel configuration.""" + """Test poll control cluster handler configuration.""" await poll_control_ch.async_configure() assert poll_control_ch.cluster.write_attributes.call_count == 1 assert poll_control_ch.cluster.write_attributes.call_args[0][0] == { @@ -549,7 +560,7 @@ async def test_poll_control_configure(poll_control_ch) -> None: async def test_poll_control_checkin_response(poll_control_ch) -> None: - """Test poll control channel checkin response.""" + """Test poll control cluster handler checkin response.""" rsp_mock = AsyncMock() set_interval_mock = AsyncMock() fast_poll_mock = AsyncMock() @@ -576,9 +587,9 @@ async def test_poll_control_checkin_response(poll_control_ch) -> None: async def test_poll_control_cluster_command( hass: HomeAssistant, poll_control_device ) -> None: - """Test poll control channel response to cluster command.""" + """Test poll control cluster handler response to cluster command.""" checkin_mock = AsyncMock() - poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] cluster = poll_control_ch.cluster events = async_capture_events(hass, zha_const.ZHA_EVENT) @@ -607,9 +618,9 @@ async def test_poll_control_cluster_command( async def test_poll_control_ignore_list( hass: HomeAssistant, poll_control_device ) -> None: - """Test poll control channel ignore list.""" + """Test poll control cluster handler ignore list.""" set_long_poll_mock = AsyncMock() - poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] cluster = poll_control_ch.cluster with mock.patch.object(cluster, "set_long_poll_interval", set_long_poll_mock): @@ -626,9 +637,9 @@ async def test_poll_control_ignore_list( async def test_poll_control_ikea(hass: HomeAssistant, poll_control_device) -> None: - """Test poll control channel ignore list for ikea.""" + """Test poll control cluster handler ignore list for ikea.""" set_long_poll_mock = AsyncMock() - poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] cluster = poll_control_ch.cluster poll_control_device.device.node_desc.manufacturer_code = 4476 @@ -651,12 +662,12 @@ def zigpy_zll_device(zigpy_device_mock): async def test_zll_device_groups( - zigpy_zll_device, channel_pool, zigpy_coordinator_device + zigpy_zll_device, endpoint, zigpy_coordinator_device ) -> None: """Test adding coordinator to ZLL groups.""" cluster = zigpy_zll_device.endpoints[1].lightlink - channel = zha_channels.lightlink.LightLink(cluster, channel_pool) + cluster_handler = cluster_handlers.lightlink.LightLink(cluster, endpoint) get_group_identifiers_rsp = zigpy.zcl.clusters.lightlink.LightLink.commands_by_name[ "get_group_identifiers_rsp" @@ -671,7 +682,7 @@ async def test_zll_device_groups( ) ), ) as cmd_mock: - await channel.async_configure() + await cluster_handler.async_configure() assert cmd_mock.await_count == 1 assert ( cluster.server_commands[cmd_mock.await_args[0][0]].name @@ -693,7 +704,7 @@ async def test_zll_device_groups( ) ), ) as cmd_mock: - await channel.async_configure() + await cluster_handler.async_configure() assert cmd_mock.await_count == 1 assert ( cluster.server_commands[cmd_mock.await_args[0][0]].name @@ -711,37 +722,38 @@ async def test_zll_device_groups( ) -@mock.patch( - "homeassistant.components.zha.core.channels.ChannelPool.add_client_channels" -) @mock.patch( "homeassistant.components.zha.core.discovery.PROBE.discover_entities", mock.MagicMock(), ) -async def test_cluster_no_ep_attribute(m1, zha_device_mock) -> None: - """Test channels for clusters without ep_attribute.""" +async def test_cluster_no_ep_attribute( + zha_device_mock: Callable[..., ZHADevice] +) -> None: + """Test cluster handlers for clusters without ep_attribute.""" zha_device = zha_device_mock( {1: {SIG_EP_INPUT: [0x042E], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, ) - channels = zha_channels.Channels.new(zha_device) - pools = {pool.id: pool for pool in channels.pools} - assert "1:0x042e" in pools[1].all_channels - assert pools[1].all_channels["1:0x042e"].name + assert "1:0x042e" in zha_device._endpoints[1].all_cluster_handlers + assert zha_device._endpoints[1].all_cluster_handlers["1:0x042e"].name + + zha_device.async_cleanup_handles() -async def test_configure_reporting(hass: HomeAssistant) -> None: - """Test setting up a channel and configuring attribute reporting in two batches.""" +async def test_configure_reporting(hass: HomeAssistant, endpoint) -> None: + """Test setting up a cluster handler and configuring attribute reporting in two batches.""" - class TestZigbeeChannel(base_channels.ZigbeeChannel): + class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): BIND = True REPORT_CONFIG = ( # By name - base_channels.AttrReportConfig(attr="current_x", config=(1, 60, 1)), - base_channels.AttrReportConfig(attr="current_hue", config=(1, 60, 2)), - base_channels.AttrReportConfig(attr="color_temperature", config=(1, 60, 3)), - base_channels.AttrReportConfig(attr="current_y", config=(1, 60, 4)), + cluster_handlers.AttrReportConfig(attr="current_x", config=(1, 60, 1)), + cluster_handlers.AttrReportConfig(attr="current_hue", config=(1, 60, 2)), + cluster_handlers.AttrReportConfig( + attr="color_temperature", config=(1, 60, 3) + ), + cluster_handlers.AttrReportConfig(attr="current_y", config=(1, 60, 4)), ) mock_ep = mock.AsyncMock(spec_set=zigpy.endpoint.Endpoint) @@ -761,11 +773,8 @@ async def test_configure_reporting(hass: HomeAssistant) -> None: ], ) - ch_pool = mock.AsyncMock(spec_set=zha_channels.ChannelPool) - ch_pool.skip_configuration = False - - channel = TestZigbeeChannel(cluster, ch_pool) - await channel.async_configure() + cluster_handler = TestZigbeeClusterHandler(cluster, endpoint) + await cluster_handler.async_configure() # Since we request reporting for five attributes, we need to make two calls (3 + 1) assert cluster.configure_reporting_multiple.mock_calls == [ diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d9556451996..7c0d3eac2a9 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -191,23 +191,30 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non ) assert result1["step_id"] == "manual_port_config" - # Confirm port settings + # Confirm the radio is deprecated result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + assert result2["step_id"] == "verify_radio" + assert "ZiGate" in result2["description_placeholders"]["name"] + + # Confirm port settings + result3 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={} ) - assert result2["type"] == FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result3["type"] == FlowResultType.MENU + assert result3["step_id"] == "choose_formation_strategy" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:1234" - assert result3["data"] == { + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "socket://192.168.1.200:1234" + assert result4["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", }, @@ -433,21 +440,26 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) + assert result2["step_id"] == "verify_radio" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result3["type"] == FlowResultType.MENU + assert result3["step_id"] == "choose_formation_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "zigate radio" - assert result3["data"] == { + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "zigate radio" + assert result4["data"] == { "device": { "path": "/dev/ttyZIGBEE", }, diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index bbf69ab2cdb..411d7081577 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -46,9 +46,9 @@ def required_platforms_only(): def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" - def _dev(with_basic_channel: bool = True, **kwargs): + def _dev(with_basic_cluster_handler: bool = True, **kwargs): in_clusters = [general.OnOff.cluster_id] - if with_basic_channel: + if with_basic_cluster_handler: in_clusters.append(general.Basic.cluster_id) endpoints = { @@ -67,9 +67,9 @@ def zigpy_device(zigpy_device_mock): def zigpy_device_mains(zigpy_device_mock): """Device tracker zigpy device.""" - def _dev(with_basic_channel: bool = True): + def _dev(with_basic_cluster_handler: bool = True): in_clusters = [general.OnOff.cluster_id] - if with_basic_channel: + if with_basic_cluster_handler: in_clusters.append(general.Basic.cluster_id) endpoints = { @@ -87,15 +87,15 @@ def zigpy_device_mains(zigpy_device_mock): @pytest.fixture -def device_with_basic_channel(zigpy_device_mains): - """Return a ZHA device with a basic channel present.""" - return zigpy_device_mains(with_basic_channel=True) +def device_with_basic_cluster_handler(zigpy_device_mains): + """Return a ZHA device with a basic cluster handler present.""" + return zigpy_device_mains(with_basic_cluster_handler=True) @pytest.fixture -def device_without_basic_channel(zigpy_device): - """Return a ZHA device with a basic channel present.""" - return zigpy_device(with_basic_channel=False) +def device_without_basic_cluster_handler(zigpy_device): + """Return a ZHA device without a basic cluster handler present.""" + return zigpy_device(with_basic_cluster_handler=False) @pytest.fixture @@ -125,32 +125,32 @@ def _send_time_changed(hass, seconds): @patch( - "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + "homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize", new=mock.AsyncMock(), ) async def test_check_available_success( - hass: HomeAssistant, device_with_basic_channel, zha_device_restored + hass: HomeAssistant, device_with_basic_cluster_handler, zha_device_restored ) -> None: """Check device availability success on 1st try.""" - zha_device = await zha_device_restored(device_with_basic_channel) + zha_device = await zha_device_restored(device_with_basic_cluster_handler) await async_enable_traffic(hass, [zha_device]) - basic_ch = device_with_basic_channel.endpoints[3].basic + basic_ch = device_with_basic_cluster_handler.endpoints[3].basic basic_ch.read_attributes.reset_mock() - device_with_basic_channel.last_seen = None + device_with_basic_cluster_handler.last_seen = None assert zha_device.available is True _send_time_changed(hass, zha_device.consider_unavailable_time + 2) await hass.async_block_till_done() assert zha_device.available is False assert basic_ch.read_attributes.await_count == 0 - device_with_basic_channel.last_seen = ( + device_with_basic_cluster_handler.last_seen = ( time.time() - zha_device.consider_unavailable_time - 2 ) - _seens = [time.time(), device_with_basic_channel.last_seen] + _seens = [time.time(), device_with_basic_cluster_handler.last_seen] def _update_last_seen(*args, **kwargs): - device_with_basic_channel.last_seen = _seens.pop() + device_with_basic_cluster_handler.last_seen = _seens.pop() basic_ch.read_attributes.side_effect = _update_last_seen @@ -177,22 +177,22 @@ async def test_check_available_success( @patch( - "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + "homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize", new=mock.AsyncMock(), ) async def test_check_available_unsuccessful( - hass: HomeAssistant, device_with_basic_channel, zha_device_restored + hass: HomeAssistant, device_with_basic_cluster_handler, zha_device_restored ) -> None: """Check device availability all tries fail.""" - zha_device = await zha_device_restored(device_with_basic_channel) + zha_device = await zha_device_restored(device_with_basic_cluster_handler) await async_enable_traffic(hass, [zha_device]) - basic_ch = device_with_basic_channel.endpoints[3].basic + basic_ch = device_with_basic_cluster_handler.endpoints[3].basic assert zha_device.available is True assert basic_ch.read_attributes.await_count == 0 - device_with_basic_channel.last_seen = ( + device_with_basic_cluster_handler.last_seen = ( time.time() - zha_device.consider_unavailable_time - 2 ) @@ -219,24 +219,24 @@ async def test_check_available_unsuccessful( @patch( - "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + "homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize", new=mock.AsyncMock(), ) -async def test_check_available_no_basic_channel( +async def test_check_available_no_basic_cluster_handler( hass: HomeAssistant, - device_without_basic_channel, + device_without_basic_cluster_handler, zha_device_restored, caplog: pytest.LogCaptureFixture, ) -> None: """Check device availability for a device without basic cluster.""" caplog.set_level(logging.DEBUG, logger="homeassistant.components.zha") - zha_device = await zha_device_restored(device_without_basic_channel) + zha_device = await zha_device_restored(device_without_basic_cluster_handler) await async_enable_traffic(hass, [zha_device]) assert zha_device.available is True - device_without_basic_channel.last_seen = ( + device_without_basic_cluster_handler.last_seen = ( time.time() - zha_device.consider_unavailable_time - 2 ) @@ -248,9 +248,9 @@ async def test_check_available_no_basic_channel( async def test_ota_sw_version(hass: HomeAssistant, ota_zha_device) -> None: - """Test device entry gets sw_version updated via OTA channel.""" + """Test device entry gets sw_version updated via OTA cluster handler.""" - ota_ch = ota_zha_device.channels.pools[0].client_channels["1:0x0019"] + ota_ch = ota_zha_device._endpoints[1].client_cluster_handlers["1:0x0019"] dev_registry = dr.async_get(hass) entry = dev_registry.async_get(ota_zha_device.device_id) assert entry.sw_version is None diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index cd12651a8e5..6db138ebcde 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -302,8 +302,8 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: await hass.async_block_till_done() calls = async_mock_service(hass, DOMAIN, "warning_device_warn") - channel = zha_device.channels.pools[0].client_channels["1:0x0006"] - channel.zha_send_event(COMMAND_SINGLE, []) + cluster_handler = zha_device.endpoints[1].client_cluster_handlers["1:0x0006"] + cluster_handler.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() assert len(calls) == 1 @@ -350,8 +350,8 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: async def test_invalid_zha_event_type(hass: HomeAssistant, device_ias) -> None: """Test that unexpected types are not passed to `zha_send_event`.""" zigpy_device, zha_device = device_ias - channel = zha_device.channels.pools[0].client_channels["1:0x0006"] + cluster_handler = zha_device._endpoints[1].client_cluster_handlers["1:0x0006"] # `zha_send_event` accepts only zigpy responses, lists, and dicts with pytest.raises(TypeError): - channel.zha_send_event(COMMAND_SINGLE, 123) + cluster_handler.zha_send_event(COMMAND_SINGLE, 123) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 531b88aee31..29920eab836 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -9,6 +9,7 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -190,7 +191,7 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No zigpy_device.device_automation_triggers = { (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, (DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE}, - (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}, + (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE, ATTR_ENDPOINT_ID: 1}, (LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD}, (LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD}, } @@ -223,8 +224,8 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No await hass.async_block_till_done() - channel = zha_device.channels.pools[0].client_channels["1:0x0006"] - channel.zha_send_event(COMMAND_SINGLE, []) + cluster_handler = zha_device.endpoints[1].client_cluster_handlers["1:0x0006"] + cluster_handler.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 20db04d9615..236a3c4ad86 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -1,5 +1,7 @@ """Test ZHA device discovery.""" +from collections.abc import Callable import re +from typing import Any from unittest import mock from unittest.mock import AsyncMock, Mock, patch @@ -14,10 +16,11 @@ import zigpy.zcl.clusters.security import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.binary_sensor -import homeassistant.components.zha.core.channels as zha_channels -import homeassistant.components.zha.core.channels.base as base_channels +import homeassistant.components.zha.core.cluster_handlers as cluster_handlers import homeassistant.components.zha.core.const as zha_const +from homeassistant.components.zha.core.device import ZHADevice import homeassistant.components.zha.core.discovery as disc +from homeassistant.components.zha.core.endpoint import Endpoint import homeassistant.components.zha.core.registries as zha_regs import homeassistant.components.zha.cover import homeassistant.components.zha.device_tracker @@ -33,11 +36,12 @@ import homeassistant.helpers.entity_registry as er from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .zha_devices_list import ( - DEV_SIG_CHANNELS, + DEV_SIG_ATTRIBUTES, + DEV_SIG_CLUSTER_HANDLERS, DEV_SIG_ENT_MAP, DEV_SIG_ENT_MAP_CLASS, DEV_SIG_ENT_MAP_ID, - DEV_SIG_EVT_CHANNELS, + DEV_SIG_EVT_CLUSTER_HANDLERS, DEVICES, ) @@ -63,27 +67,6 @@ def contains_ignored_suffix(unique_id: str) -> bool: return False -@pytest.fixture -def channels_mock(zha_device_mock): - """Channels mock factory.""" - - def _mock( - endpoints, - ieee="00:11:22:33:44:55:66:77", - manufacturer="mock manufacturer", - model="mock model", - node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", - patch_cluster=False, - ): - zha_dev = zha_device_mock( - endpoints, ieee, manufacturer, model, node_desc, patch_cluster=patch_cluster - ) - channels = zha_channels.Channels.new(zha_dev) - return channels - - return _mock - - @patch( "zigpy.zcl.clusters.general.Identify.request", new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), @@ -107,11 +90,12 @@ async def test_devices( entity_registry = er.async_get(hass_disable_services) zigpy_device = zigpy_device_mock( - device[SIG_ENDPOINTS], - "00:11:22:33:44:55:66:77", - device[SIG_MANUFACTURER], - device[SIG_MODEL], + endpoints=device[SIG_ENDPOINTS], + ieee="00:11:22:33:44:55:66:77", + manufacturer=device[SIG_MANUFACTURER], + model=device[SIG_MODEL], node_descriptor=device[SIG_NODE_DESC], + attributes=device.get(DEV_SIG_ATTRIBUTES), patch_cluster=False, ) @@ -119,62 +103,71 @@ async def test_devices( if cluster_identify: cluster_identify.request.reset_mock() - orig_new_entity = zha_channels.ChannelPool.async_new_entity + orig_new_entity = Endpoint.async_new_entity _dispatch = mock.MagicMock(wraps=orig_new_entity) try: - zha_channels.ChannelPool.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw) + Endpoint.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw) zha_dev = await zha_device_joined_restored(zigpy_device) await hass_disable_services.async_block_till_done() finally: - zha_channels.ChannelPool.async_new_entity = orig_new_entity + Endpoint.async_new_entity = orig_new_entity if cluster_identify: - called = int(zha_device_joined_restored.name == "zha_device_joined") - assert cluster_identify.request.call_count == called - assert cluster_identify.request.await_count == called - if called: - assert cluster_identify.request.call_args == mock.call( - False, - cluster_identify.commands_by_name["trigger_effect"].id, - cluster_identify.commands_by_name["trigger_effect"].schema, - effect_id=zigpy.zcl.clusters.general.Identify.EffectIdentifier.Okay, - effect_variant=( - zigpy.zcl.clusters.general.Identify.EffectVariant.Default - ), - expect_reply=True, - manufacturer=None, - tries=1, - tsn=None, - ) + # We only identify on join + should_identify = ( + zha_device_joined_restored.name == "zha_device_joined" + and not zigpy_device.skip_configuration + ) - event_channels = { - ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() + if should_identify: + assert cluster_identify.request.mock_calls == [ + mock.call( + False, + cluster_identify.commands_by_name["trigger_effect"].id, + cluster_identify.commands_by_name["trigger_effect"].schema, + effect_id=zigpy.zcl.clusters.general.Identify.EffectIdentifier.Okay, + effect_variant=( + zigpy.zcl.clusters.general.Identify.EffectVariant.Default + ), + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + ] + else: + assert cluster_identify.request.mock_calls == [] + + event_cluster_handlers = { + ch.id + for endpoint in zha_dev._endpoints.values() + for ch in endpoint.client_cluster_handlers.values() } - assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) + assert event_cluster_handlers == set(device[DEV_SIG_EVT_CLUSTER_HANDLERS]) # we need to probe the class create entity factory so we need to reset this to get accurate results zha_regs.ZHA_ENTITIES.clean_up() - # build a dict of entity_class -> (component, unique_id, channels) tuple + # build a dict of entity_class -> (platform, unique_id, cluster_handlers) tuple ha_ent_info = {} created_entity_count = 0 for call in _dispatch.call_args_list: - _, component, entity_cls, unique_id, channels = call[0] + _, platform, entity_cls, unique_id, cluster_handlers = call[0] # the factory can return None. We filter these out to get an accurate created entity count - response = entity_cls.create_entity(unique_id, zha_dev, channels) + response = entity_cls.create_entity(unique_id, zha_dev, cluster_handlers) if response and not contains_ignored_suffix(response.name): created_entity_count += 1 unique_id_head = UNIQUE_ID_HD.match(unique_id).group( 0 ) # ieee + endpoint_id ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( - component, + platform, unique_id, - channels, + cluster_handlers, ) for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items(): - component, unique_id = comp_id + platform, unique_id = comp_id no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) - ha_entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + ha_entity_id = entity_registry.async_get_entity_id(platform, "zha", unique_id) assert ha_entity_id is not None assert ha_entity_id.startswith(no_tail_id) @@ -182,13 +175,15 @@ async def test_devices( test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) assert (test_unique_id_head, test_ent_class) in ha_ent_info - ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + ha_comp, ha_unique_id, ha_cluster_handlers = ha_ent_info[ (test_unique_id_head, test_ent_class) ] - assert component is ha_comp.value + assert platform is ha_comp.value # unique_id used for discover is the same for "multi entities" assert unique_id.startswith(ha_unique_id) - assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + assert {ch.name for ch in ha_cluster_handlers} == set( + ent_info[DEV_SIG_CLUSTER_HANDLERS] + ) assert created_entity_count == len(device[DEV_SIG_ENT_MAP]) @@ -219,16 +214,16 @@ def _get_first_identify_cluster(zigpy_device): ) def test_discover_entities(m1, m2) -> None: """Test discover endpoint class method.""" - ep_channels = mock.MagicMock() - disc.PROBE.discover_entities(ep_channels) + endpoint = mock.MagicMock() + disc.PROBE.discover_entities(endpoint) assert m1.call_count == 1 - assert m1.call_args[0][0] is ep_channels + assert m1.call_args[0][0] is endpoint assert m2.call_count == 1 - assert m2.call_args[0][0] is ep_channels + assert m2.call_args[0][0] is endpoint @pytest.mark.parametrize( - ("device_type", "component", "hit"), + ("device_type", "platform", "hit"), [ (zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, Platform.LIGHT, True), (zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST, Platform.SWITCH, True), @@ -236,14 +231,14 @@ def test_discover_entities(m1, m2) -> None: (0xFFFF, None, False), ], ) -def test_discover_by_device_type(device_type, component, hit) -> None: +def test_discover_by_device_type(device_type, platform, hit) -> None: """Test entity discovery by device type.""" - ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + endpoint = mock.MagicMock(spec_set=Endpoint) ep_mock = mock.PropertyMock() ep_mock.return_value.profile_id = 0x0104 ep_mock.return_value.device_type = device_type - type(ep_channels).endpoint = ep_mock + type(endpoint).zigpy_endpoint = ep_mock get_entity_mock = mock.MagicMock( return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) @@ -252,26 +247,26 @@ def test_discover_by_device_type(device_type, component, hit) -> None: "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", get_entity_mock, ): - disc.PROBE.discover_by_device_type(ep_channels) + disc.PROBE.discover_by_device_type(endpoint) if hit: assert get_entity_mock.call_count == 1 - assert ep_channels.claim_channels.call_count == 1 - assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed - assert ep_channels.async_new_entity.call_count == 1 - assert ep_channels.async_new_entity.call_args[0][0] == component - assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == platform + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls def test_discover_by_device_type_override() -> None: """Test entity discovery by device type overriding.""" - ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + endpoint = mock.MagicMock(spec_set=Endpoint) ep_mock = mock.PropertyMock() ep_mock.return_value.profile_id = 0x0104 ep_mock.return_value.device_type = 0x0100 - type(ep_channels).endpoint = ep_mock + type(endpoint).zigpy_endpoint = ep_mock - overrides = {ep_channels.unique_id: {"type": Platform.SWITCH}} + overrides = {endpoint.unique_id: {"type": Platform.SWITCH}} get_entity_mock = mock.MagicMock( return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) ) @@ -279,99 +274,109 @@ def test_discover_by_device_type_override() -> None: "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", get_entity_mock, ), mock.patch.dict(disc.PROBE._device_configs, overrides, clear=True): - disc.PROBE.discover_by_device_type(ep_channels) + disc.PROBE.discover_by_device_type(endpoint) assert get_entity_mock.call_count == 1 - assert ep_channels.claim_channels.call_count == 1 - assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed - assert ep_channels.async_new_entity.call_count == 1 - assert ep_channels.async_new_entity.call_args[0][0] == Platform.SWITCH - assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls def test_discover_probe_single_cluster() -> None: """Test entity discovery by single cluster.""" - ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + endpoint = mock.MagicMock(spec_set=Endpoint) ep_mock = mock.PropertyMock() ep_mock.return_value.profile_id = 0x0104 ep_mock.return_value.device_type = 0x0100 - type(ep_channels).endpoint = ep_mock + type(endpoint).zigpy_endpoint = ep_mock get_entity_mock = mock.MagicMock( return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) ) - channel_mock = mock.MagicMock(spec_set=base_channels.ZigbeeChannel) + cluster_handler_mock = mock.MagicMock(spec_set=cluster_handlers.ClusterHandler) with mock.patch( "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", get_entity_mock, ): - disc.PROBE.probe_single_cluster(Platform.SWITCH, channel_mock, ep_channels) + disc.PROBE.probe_single_cluster(Platform.SWITCH, cluster_handler_mock, endpoint) assert get_entity_mock.call_count == 1 - assert ep_channels.claim_channels.call_count == 1 - assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed - assert ep_channels.async_new_entity.call_count == 1 - assert ep_channels.async_new_entity.call_args[0][0] == Platform.SWITCH - assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls - assert ep_channels.async_new_entity.call_args[0][3] == mock.sentinel.claimed + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + assert endpoint.async_new_entity.call_args[0][3] == mock.sentinel.claimed @pytest.mark.parametrize("device_info", DEVICES) async def test_discover_endpoint( - device_info, channels_mock, hass: HomeAssistant + device_info: dict[str, Any], + zha_device_mock: Callable[..., ZHADevice], + hass: HomeAssistant, ) -> None: """Test device discovery.""" with mock.patch( - "homeassistant.components.zha.core.channels.Channels.async_new_entity" + "homeassistant.components.zha.core.endpoint.Endpoint.async_new_entity" ) as new_ent: - channels = channels_mock( + device = zha_device_mock( device_info[SIG_ENDPOINTS], manufacturer=device_info[SIG_MANUFACTURER], model=device_info[SIG_MODEL], node_desc=device_info[SIG_NODE_DESC], - patch_cluster=False, + patch_cluster=True, ) - assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( - ch.id for pool in channels.pools for ch in pool.client_channels.values() + assert device_info[DEV_SIG_EVT_CLUSTER_HANDLERS] == sorted( + ch.id + for endpoint in device._endpoints.values() + for ch in endpoint.client_cluster_handlers.values() ) - # build a dict of entity_class -> (component, unique_id, channels) tuple + # build a dict of entity_class -> (platform, unique_id, cluster_handlers) tuple ha_ent_info = {} for call in new_ent.call_args_list: - component, entity_cls, unique_id, channels = call[0] + platform, entity_cls, unique_id, cluster_handlers = call[0] if not contains_ignored_suffix(unique_id): unique_id_head = UNIQUE_ID_HD.match(unique_id).group( 0 ) # ieee + endpoint_id ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( - component, + platform, unique_id, - channels, + cluster_handlers, ) - for comp_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): - component, unique_id = comp_id + for platform_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): + platform, unique_id = platform_id test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) assert (test_unique_id_head, test_ent_class) in ha_ent_info - ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + entity_platform, entity_unique_id, entity_cluster_handlers = ha_ent_info[ (test_unique_id_head, test_ent_class) ] - assert component is ha_comp.value + assert platform is entity_platform.value # unique_id used for discover is the same for "multi entities" - assert unique_id.startswith(ha_unique_id) - assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + assert unique_id.startswith(entity_unique_id) + assert {ch.name for ch in entity_cluster_handlers} == set( + ent_info[DEV_SIG_CLUSTER_HANDLERS] + ) + + device.async_cleanup_handles() def _ch_mock(cluster): - """Return mock of a channel with a cluster.""" - channel = mock.MagicMock() - type(channel).cluster = mock.PropertyMock(return_value=cluster(mock.MagicMock())) - return channel + """Return mock of a cluster_handler with a cluster.""" + cluster_handler = mock.MagicMock() + type(cluster_handler).cluster = mock.PropertyMock( + return_value=cluster(mock.MagicMock()) + ) + return cluster_handler @mock.patch( @@ -401,16 +406,16 @@ def _test_single_input_cluster_device_class(probe_mock): analog_ch = _ch_mock(_Analog) - ch_pool = mock.MagicMock(spec_set=zha_channels.ChannelPool) - ch_pool.unclaimed_channels.return_value = [ + endpoint = mock.MagicMock(spec_set=Endpoint) + endpoint.unclaimed_cluster_handlers.return_value = [ door_ch, cover_ch, multistate_ch, ias_ch, ] - disc.ProbeEndpoint().discover_by_cluster_id(ch_pool) - assert probe_mock.call_count == len(ch_pool.unclaimed_channels()) + disc.ProbeEndpoint().discover_by_cluster_id(endpoint) + assert probe_mock.call_count == len(endpoint.unclaimed_cluster_handlers()) probes = ( (Platform.LOCK, door_ch), (Platform.COVER, cover_ch), @@ -419,8 +424,8 @@ def _test_single_input_cluster_device_class(probe_mock): (Platform.SENSOR, analog_ch), ) for call, details in zip(probe_mock.call_args_list, probes): - component, ch = details - assert call[0][0] == component + platform, ch = details + assert call[0][0] == platform assert call[0][1] == ch @@ -498,7 +503,7 @@ async def test_group_probe_cleanup_called( "homeassistant.components.zha.entity.ZhaEntity.entity_registry_enabled_default", new=Mock(return_value=True), ) -async def test_channel_with_empty_ep_attribute_cluster( +async def test_cluster_handler_with_empty_ep_attribute_cluster( hass_disable_services, zigpy_device_mock, zha_device_joined_restored, diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index be53b22be6a..c58aaedcbbc 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,5 +1,6 @@ """Test ZHA Gateway.""" import asyncio +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -8,6 +9,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting +from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.group import GroupMember from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -238,7 +240,10 @@ async def test_gateway_create_group_with_id( ], ) async def test_gateway_initialize_success( - startup, hass: HomeAssistant, device_light_1, coordinator + startup: list[Any], + hass: HomeAssistant, + device_light_1: ZHADevice, + coordinator: ZHADevice, ) -> None: """Test ZHA initializing the gateway successfully.""" zha_gateway = get_zha_gateway(hass) @@ -253,6 +258,8 @@ async def test_gateway_initialize_success( assert mock_new.call_count == len(startup) + device_light_1.async_cleanup_handles() + @patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) async def test_gateway_initialize_failure( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 5070aa770e4..c4751f7e7f6 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -569,7 +569,7 @@ async def test_transitions( "turn_on", { "entity_id": device_1_entity_id, - "transition": 3, + "transition": 3.5, "brightness": 18, "color_temp": 432, }, @@ -586,7 +586,7 @@ async def test_transitions( dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, level=18, - transition_time=30, + transition_time=35, expect_reply=True, manufacturer=None, tries=1, @@ -597,7 +597,7 @@ async def test_transitions( dev1_cluster_color.commands_by_name["move_to_color_temp"].id, dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, color_temp_mireds=432, - transition_time=30.0, + transition_time=35, expect_reply=True, manufacturer=None, tries=1, @@ -1036,18 +1036,18 @@ async def test_transitions( blocking=True, ) - group_on_off_channel = zha_group.endpoint[general.OnOff.cluster_id] - group_level_channel = zha_group.endpoint[general.LevelControl.cluster_id] - group_color_channel = zha_group.endpoint[lighting.Color.cluster_id] - assert group_on_off_channel.request.call_count == 0 - assert group_on_off_channel.request.await_count == 0 - assert group_color_channel.request.call_count == 1 - assert group_color_channel.request.await_count == 1 - assert group_level_channel.request.call_count == 1 - assert group_level_channel.request.await_count == 1 + group_on_off_cluster_handler = zha_group.endpoint[general.OnOff.cluster_id] + group_level_cluster_handler = zha_group.endpoint[general.LevelControl.cluster_id] + group_color_cluster_handler = zha_group.endpoint[lighting.Color.cluster_id] + assert group_on_off_cluster_handler.request.call_count == 0 + assert group_on_off_cluster_handler.request.await_count == 0 + assert group_color_cluster_handler.request.call_count == 1 + assert group_color_cluster_handler.request.await_count == 1 + assert group_level_cluster_handler.request.call_count == 1 + assert group_level_cluster_handler.request.await_count == 1 # groups are omitted from the 3 call dance for new_color_provided_while_off - assert group_color_channel.request.call_args == call( + assert group_color_cluster_handler.request.call_args == call( False, dev2_cluster_color.commands_by_name["move_to_color_temp"].id, dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, @@ -1058,7 +1058,7 @@ async def test_transitions( tries=1, tsn=None, ) - assert group_level_channel.request.call_args == call( + assert group_level_cluster_handler.request.call_args == call( False, dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, @@ -1076,9 +1076,9 @@ async def test_transitions( assert group_state.attributes["color_temp"] == 235 assert group_state.attributes["color_mode"] == ColorMode.COLOR_TEMP - group_on_off_channel.request.reset_mock() - group_color_channel.request.reset_mock() - group_level_channel.request.reset_mock() + group_on_off_cluster_handler.request.reset_mock() + group_color_cluster_handler.request.reset_mock() + group_level_cluster_handler.request.reset_mock() # turn the sengled light back on await hass.services.async_call( diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 80b1f10f561..057921f80a9 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,14 +1,21 @@ """Test ZHA registries.""" +from __future__ import annotations + import importlib import inspect +import typing from unittest import mock import pytest import zhaquirks +from homeassistant.components.zha.binary_sensor import IASZone import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er +if typing.TYPE_CHECKING: + from homeassistant.components.zha.core.entity import ZhaEntity + MANUFACTURER = "mock manufacturer" MODEL = "mock model" QUIRK_CLASS = "mock.class" @@ -25,36 +32,48 @@ def zha_device(): @pytest.fixture -def channels(channel): - """Return a mock of channels.""" +def cluster_handlers(cluster_handler): + """Return a mock of cluster_handlers.""" - return [channel("level", 8), channel("on_off", 6)] + return [cluster_handler("level", 8), cluster_handler("on_off", 6)] @pytest.mark.parametrize( ("rule", "matched"), [ (registries.MatchRule(), False), - (registries.MatchRule(channel_names={"level"}), True), - (registries.MatchRule(channel_names={"level", "no match"}), False), - (registries.MatchRule(channel_names={"on_off"}), True), - (registries.MatchRule(channel_names={"on_off", "no match"}), False), - (registries.MatchRule(channel_names={"on_off", "level"}), True), - (registries.MatchRule(channel_names={"on_off", "level", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"level"}), True), + (registries.MatchRule(cluster_handler_names={"level", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"on_off"}), True), + (registries.MatchRule(cluster_handler_names={"on_off", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"on_off", "level"}), True), + ( + registries.MatchRule(cluster_handler_names={"on_off", "level", "no match"}), + False, + ), # test generic_id matching - (registries.MatchRule(generic_ids={"channel_0x0006"}), True), - (registries.MatchRule(generic_ids={"channel_0x0008"}), True), - (registries.MatchRule(generic_ids={"channel_0x0006", "channel_0x0008"}), True), + (registries.MatchRule(generic_ids={"cluster_handler_0x0006"}), True), + (registries.MatchRule(generic_ids={"cluster_handler_0x0008"}), True), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008", "channel_0x0009"} + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"} + ), + True, + ), + ( + registries.MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + } ), False, ), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008"}, - channel_names={"on_off", "level"}, + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, ), True, ), @@ -62,34 +81,50 @@ def channels(channel): (registries.MatchRule(manufacturers="no match"), False), (registries.MatchRule(manufacturers=MANUFACTURER), True), ( - registries.MatchRule(manufacturers="no match", aux_channels="aux_channel"), + registries.MatchRule( + manufacturers="no match", aux_cluster_handlers="aux_cluster_handler" + ), False, ), ( registries.MatchRule( - manufacturers=MANUFACTURER, aux_channels="aux_channel" + manufacturers=MANUFACTURER, aux_cluster_handlers="aux_cluster_handler" ), True, ), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), - (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True), - (registries.MatchRule(models="no match", aux_channels="aux_channel"), False), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), ( - registries.MatchRule(quirk_classes=QUIRK_CLASS, aux_channels="aux_channel"), + registries.MatchRule( + models=MODEL, aux_cluster_handlers="aux_cluster_handler" + ), True, ), ( - registries.MatchRule(quirk_classes="no match", aux_channels="aux_channel"), + registries.MatchRule( + models="no match", aux_cluster_handlers="aux_cluster_handler" + ), + False, + ), + (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), + (registries.MatchRule(quirk_classes="no match"), False), + ( + registries.MatchRule( + quirk_classes=QUIRK_CLASS, aux_cluster_handlers="aux_cluster_handler" + ), + True, + ), + ( + registries.MatchRule( + quirk_classes="no match", aux_cluster_handlers="aux_cluster_handler" + ), False, ), # match everything ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008"}, - channel_names={"on_off", "level"}, + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, quirk_classes=QUIRK_CLASS, @@ -98,96 +133,114 @@ def channels(channel): ), ( registries.MatchRule( - channel_names="on_off", manufacturers={"random manuf", MANUFACTURER} + cluster_handler_names="on_off", + manufacturers={"random manuf", MANUFACTURER}, ), True, ), ( registries.MatchRule( - channel_names="on_off", manufacturers={"random manuf", "Another manuf"} + cluster_handler_names="on_off", + manufacturers={"random manuf", "Another manuf"}, ), False, ), ( registries.MatchRule( - channel_names="on_off", manufacturers=lambda x: x == MANUFACTURER + cluster_handler_names="on_off", + manufacturers=lambda x: x == MANUFACTURER, ), True, ), ( registries.MatchRule( - channel_names="on_off", manufacturers=lambda x: x != MANUFACTURER + cluster_handler_names="on_off", + manufacturers=lambda x: x != MANUFACTURER, ), False, ), ( registries.MatchRule( - channel_names="on_off", models={"random model", MODEL} + cluster_handler_names="on_off", models={"random model", MODEL} ), True, ), ( registries.MatchRule( - channel_names="on_off", models={"random model", "Another model"} - ), - False, - ), - ( - registries.MatchRule(channel_names="on_off", models=lambda x: x == MODEL), - True, - ), - ( - registries.MatchRule(channel_names="on_off", models=lambda x: x != MODEL), - False, - ), - ( - registries.MatchRule( - channel_names="on_off", quirk_classes={"random quirk", QUIRK_CLASS} - ), - True, - ), - ( - registries.MatchRule( - channel_names="on_off", quirk_classes={"random quirk", "another quirk"} + cluster_handler_names="on_off", models={"random model", "Another model"} ), False, ), ( registries.MatchRule( - channel_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + cluster_handler_names="on_off", models=lambda x: x == MODEL ), True, ), ( registries.MatchRule( - channel_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + cluster_handler_names="on_off", models=lambda x: x != MODEL + ), + False, + ), + ( + registries.MatchRule( + cluster_handler_names="on_off", + quirk_classes={"random quirk", QUIRK_CLASS}, + ), + True, + ), + ( + registries.MatchRule( + cluster_handler_names="on_off", + quirk_classes={"random quirk", "another quirk"}, + ), + False, + ), + ( + registries.MatchRule( + cluster_handler_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + ), + True, + ), + ( + registries.MatchRule( + cluster_handler_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS ), False, ), ], ) -def test_registry_matching(rule, matched, channels) -> None: +def test_registry_matching(rule, matched, cluster_handlers) -> None: """Test strict rule matching.""" - assert rule.strict_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched + assert ( + rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) + is matched + ) @pytest.mark.parametrize( ("rule", "matched"), [ (registries.MatchRule(), False), - (registries.MatchRule(channel_names={"level"}), True), - (registries.MatchRule(channel_names={"level", "no match"}), False), - (registries.MatchRule(channel_names={"on_off"}), True), - (registries.MatchRule(channel_names={"on_off", "no match"}), False), - (registries.MatchRule(channel_names={"on_off", "level"}), True), - (registries.MatchRule(channel_names={"on_off", "level", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"level"}), True), + (registries.MatchRule(cluster_handler_names={"level", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"on_off"}), True), + (registries.MatchRule(cluster_handler_names={"on_off", "no match"}), False), + (registries.MatchRule(cluster_handler_names={"on_off", "level"}), True), ( - registries.MatchRule(channel_names={"on_off", "level"}, models="no match"), + registries.MatchRule(cluster_handler_names={"on_off", "level", "no match"}), + False, + ), + ( + registries.MatchRule( + cluster_handler_names={"on_off", "level"}, models="no match" + ), True, ), ( registries.MatchRule( - channel_names={"on_off", "level"}, + cluster_handler_names={"on_off", "level"}, models="no match", manufacturers="no match", ), @@ -195,40 +248,57 @@ def test_registry_matching(rule, matched, channels) -> None: ), ( registries.MatchRule( - channel_names={"on_off", "level"}, + cluster_handler_names={"on_off", "level"}, models="no match", manufacturers=MANUFACTURER, ), True, ), # test generic_id matching - (registries.MatchRule(generic_ids={"channel_0x0006"}), True), - (registries.MatchRule(generic_ids={"channel_0x0008"}), True), - (registries.MatchRule(generic_ids={"channel_0x0006", "channel_0x0008"}), True), + (registries.MatchRule(generic_ids={"cluster_handler_0x0006"}), True), + (registries.MatchRule(generic_ids={"cluster_handler_0x0008"}), True), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008", "channel_0x0009"} + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"} + ), + True, + ), + ( + registries.MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + } ), False, ), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008", "channel_0x0009"}, + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + }, models="mo match", ), False, ), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008", "channel_0x0009"}, + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + }, models=MODEL, ), True, ), ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008"}, - channel_names={"on_off", "level"}, + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, ), True, ), @@ -242,8 +312,8 @@ def test_registry_matching(rule, matched, channels) -> None: # match everything ( registries.MatchRule( - generic_ids={"channel_0x0006", "channel_0x0008"}, - channel_names={"on_off", "level"}, + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, quirk_classes=QUIRK_CLASS, @@ -252,51 +322,64 @@ def test_registry_matching(rule, matched, channels) -> None: ), ], ) -def test_registry_loose_matching(rule, matched, channels) -> None: +def test_registry_loose_matching(rule, matched, cluster_handlers) -> None: """Test loose rule matching.""" - assert rule.loose_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched + assert ( + rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) + is matched + ) -def test_match_rule_claim_channels_color(channel) -> None: - """Test channel claiming.""" - ch_color = channel("color", 0x300) - ch_level = channel("level", 8) - ch_onoff = channel("on_off", 6) +def test_match_rule_claim_cluster_handlers_color(cluster_handler) -> None: + """Test cluster handler claiming.""" + ch_color = cluster_handler("color", 0x300) + ch_level = cluster_handler("level", 8) + ch_onoff = cluster_handler("on_off", 6) - rule = registries.MatchRule(channel_names="on_off", aux_channels={"color", "level"}) - claimed = rule.claim_channels([ch_color, ch_level, ch_onoff]) + rule = registries.MatchRule( + cluster_handler_names="on_off", aux_cluster_handlers={"color", "level"} + ) + claimed = rule.claim_cluster_handlers([ch_color, ch_level, ch_onoff]) assert {"color", "level", "on_off"} == {ch.name for ch in claimed} @pytest.mark.parametrize( ("rule", "match"), [ - (registries.MatchRule(channel_names={"level"}), {"level"}), - (registries.MatchRule(channel_names={"level", "no match"}), {"level"}), - (registries.MatchRule(channel_names={"on_off"}), {"on_off"}), - (registries.MatchRule(generic_ids="channel_0x0000"), {"basic"}), - ( - registries.MatchRule(channel_names="level", generic_ids="channel_0x0000"), - {"basic", "level"}, - ), - (registries.MatchRule(channel_names={"level", "power"}), {"level", "power"}), + (registries.MatchRule(cluster_handler_names={"level"}), {"level"}), + (registries.MatchRule(cluster_handler_names={"level", "no match"}), {"level"}), + (registries.MatchRule(cluster_handler_names={"on_off"}), {"on_off"}), + (registries.MatchRule(generic_ids="cluster_handler_0x0000"), {"basic"}), ( registries.MatchRule( - channel_names={"level", "on_off"}, aux_channels={"basic", "power"} + cluster_handler_names="level", generic_ids="cluster_handler_0x0000" + ), + {"basic", "level"}, + ), + ( + registries.MatchRule(cluster_handler_names={"level", "power"}), + {"level", "power"}, + ), + ( + registries.MatchRule( + cluster_handler_names={"level", "on_off"}, + aux_cluster_handlers={"basic", "power"}, ), {"basic", "level", "on_off", "power"}, ), - (registries.MatchRule(channel_names={"color"}), set()), + (registries.MatchRule(cluster_handler_names={"color"}), set()), ], ) -def test_match_rule_claim_channels(rule, match, channel, channels) -> None: - """Test channel claiming.""" - ch_basic = channel("basic", 0) - channels.append(ch_basic) - ch_power = channel("power", 1) - channels.append(ch_power) +def test_match_rule_claim_cluster_handlers( + rule, match, cluster_handler, cluster_handlers +) -> None: + """Test cluster handler claiming.""" + ch_basic = cluster_handler("basic", 0) + cluster_handlers.append(ch_basic) + ch_power = cluster_handler("power", 1) + cluster_handlers.append(ch_power) - claimed = rule.claim_channels(channels) + claimed = rule.claim_cluster_handlers(cluster_handlers) assert match == {ch.name for ch in claimed} @@ -318,7 +401,7 @@ def entity_registry(): ), ) def test_weighted_match( - channel, + cluster_handler, entity_registry: er.EntityRegistry, manufacturer, model, @@ -331,40 +414,45 @@ def test_weighted_match( @entity_registry.strict_match( s.component, - channel_names="on_off", + cluster_handler_names="on_off", models={MODEL, "another model", "some model"}, ) class OnOffMultimodel: pass - @entity_registry.strict_match(s.component, channel_names="on_off") + @entity_registry.strict_match(s.component, cluster_handler_names="on_off") class OnOff: pass @entity_registry.strict_match( - s.component, channel_names="on_off", manufacturers=MANUFACTURER + s.component, cluster_handler_names="on_off", manufacturers=MANUFACTURER ) class OnOffManufacturer: pass - @entity_registry.strict_match(s.component, channel_names="on_off", models=MODEL) + @entity_registry.strict_match( + s.component, cluster_handler_names="on_off", models=MODEL + ) class OnOffModel: pass @entity_registry.strict_match( - s.component, channel_names="on_off", models=MODEL, manufacturers=MANUFACTURER + s.component, + cluster_handler_names="on_off", + models=MODEL, + manufacturers=MANUFACTURER, ) class OnOffModelManufacturer: pass @entity_registry.strict_match( - s.component, channel_names="on_off", quirk_classes=QUIRK_CLASS + s.component, cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS ) class OnOffQuirk: pass - ch_on_off = channel("on_off", 6) - ch_level = channel("level", 8) + ch_on_off = cluster_handler("on_off", 6) + ch_level = cluster_handler("level", 8) match, claimed = entity_registry.get_entity( s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class @@ -374,25 +462,27 @@ def test_weighted_match( assert claimed == [ch_on_off] -def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None: +def test_multi_sensor_match( + cluster_handler, entity_registry: er.EntityRegistry +) -> None: """Test multi-entity match.""" s = mock.sentinel @entity_registry.multipass_match( s.binary_sensor, - channel_names="smartenergy_metering", + cluster_handler_names="smartenergy_metering", ) class SmartEnergySensor2: pass - ch_se = channel("smartenergy_metering", 0x0702) - ch_illuminati = channel("illuminance", 0x0401) + ch_se = cluster_handler("smartenergy_metering", 0x0702) + ch_illuminati = cluster_handler("illuminance", 0x0401) match, claimed = entity_registry.get_multi_entity( "manufacturer", "model", - channels=[ch_se, ch_illuminati], + cluster_handlers=[ch_se, ch_illuminati], quirk_class="quirk_class", ) @@ -404,15 +494,17 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None } @entity_registry.multipass_match( - s.component, channel_names="smartenergy_metering", aux_channels="illuminance" + s.component, + cluster_handler_names="smartenergy_metering", + aux_cluster_handlers="illuminance", ) class SmartEnergySensor1: pass @entity_registry.multipass_match( s.binary_sensor, - channel_names="smartenergy_metering", - aux_channels="illuminance", + cluster_handler_names="smartenergy_metering", + aux_cluster_handlers="illuminance", ) class SmartEnergySensor3: pass @@ -420,7 +512,7 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None match, claimed = entity_registry.get_multi_entity( "manufacturer", "model", - channels={ch_se, ch_illuminati}, + cluster_handlers={ch_se, ch_illuminati}, quirk_class="quirk_class", ) @@ -436,6 +528,24 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None } +def iter_all_rules() -> typing.Iterable[registries.MatchRule, list[type[ZhaEntity]]]: + """Iterate over all match rules and their corresponding entities.""" + + for rules in registries.ZHA_ENTITIES._strict_registry.values(): + for rule, entity in rules.items(): + yield rule, [entity] + + for rules in registries.ZHA_ENTITIES._multi_entity_registry.values(): + for multi in rules.values(): + for rule, entities in multi.items(): + yield rule, entities + + for rules in registries.ZHA_ENTITIES._config_diagnostic_entity_registry.values(): + for multi in rules.values(): + for rule, entities in multi.items(): + yield rule, entities + + def test_quirk_classes() -> None: """Make sure that quirk_classes in components matches are valid.""" @@ -467,16 +577,18 @@ def test_quirk_classes() -> None: if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): raise ValueError(f"Quirk class '{value}' does not exists.") - for component in registries.ZHA_ENTITIES._strict_registry.items(): - for rule in component[1].items(): - quirk_class_validator(rule[0].quirk_classes) + for rule, _ in iter_all_rules(): + quirk_class_validator(rule.quirk_classes) - for component in registries.ZHA_ENTITIES._multi_entity_registry.items(): - for item in component[1].items(): - for rule in item[1].items(): - quirk_class_validator(rule[0].quirk_classes) - for component in registries.ZHA_ENTITIES._config_diagnostic_entity_registry.items(): - for item in component[1].items(): - for rule in item[1].items(): - quirk_class_validator(rule[0].quirk_classes) +def test_entity_names() -> None: + """Make sure that all handlers expose entities with valid names.""" + + for _, entities in iter_all_rules(): + for entity in entities: + if hasattr(entity, "_attr_name"): + # The entity has a name + assert isinstance(entity._attr_name, str) and entity._attr_name + else: + # The only exception (for now) is IASZone + assert entity is IASZone diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 5ef83ff454f..83799147bbe 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,6 +1,6 @@ """Test ZHA sensor.""" import math -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha @@ -10,7 +10,7 @@ import zigpy.zcl.clusters.measurement as measurement import zigpy.zcl.clusters.smartenergy as smartenergy from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.zha.core.const import ZHA_CHANNEL_READS_PER_REQ +from homeassistant.components.zha.core.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ import homeassistant.config as config_util from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -586,6 +586,10 @@ async def test_temp_uom( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == uom +@patch( + "zigpy.zcl.ClusterPersistingListener", + MagicMock(), +) async def test_electrical_measurement_init( hass: HomeAssistant, zigpy_device_mock, @@ -605,7 +609,9 @@ async def test_electrical_measurement_init( ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] zha_device = await zha_device_joined(zigpy_device) - entity_id = await find_entity_id(Platform.SENSOR, zha_device, hass) + entity_id = await find_entity_id( + Platform.SENSOR, zha_device, hass, qualifier="active_power" + ) # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) @@ -616,30 +622,30 @@ async def test_electrical_measurement_init( await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000}) assert int(hass.states.get(entity_id).state) == 100 - channel = zha_device.channels.pools[0].all_channels["1:0x0b04"] - assert channel.ac_power_divisor == 1 - assert channel.ac_power_multiplier == 1 + cluster_handler = zha_device._endpoints[1].all_cluster_handlers["1:0x0b04"] + assert cluster_handler.ac_power_divisor == 1 + assert cluster_handler.ac_power_multiplier == 1 # update power divisor await send_attributes_report(hass, cluster, {0: 1, 1291: 20, 0x0403: 5, 10: 1000}) - assert channel.ac_power_divisor == 5 - assert channel.ac_power_multiplier == 1 + assert cluster_handler.ac_power_divisor == 5 + assert cluster_handler.ac_power_multiplier == 1 assert hass.states.get(entity_id).state == "4.0" await send_attributes_report(hass, cluster, {0: 1, 1291: 30, 0x0605: 10, 10: 1000}) - assert channel.ac_power_divisor == 10 - assert channel.ac_power_multiplier == 1 + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 1 assert hass.states.get(entity_id).state == "3.0" # update power multiplier await send_attributes_report(hass, cluster, {0: 1, 1291: 20, 0x0402: 6, 10: 1000}) - assert channel.ac_power_divisor == 10 - assert channel.ac_power_multiplier == 6 + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 6 assert hass.states.get(entity_id).state == "12.0" await send_attributes_report(hass, cluster, {0: 1, 1291: 30, 0x0604: 20, 10: 1000}) - assert channel.ac_power_divisor == 10 - assert channel.ac_power_multiplier == 20 + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 20 assert hass.states.get(entity_id).state == "60.0" @@ -972,7 +978,7 @@ async def test_elec_measurement_skip_unsupported_attribute( await async_update_entity(hass, entity_id) await hass.async_block_till_done() assert cluster.read_attributes.call_count == math.ceil( - len(supported_attributes) / ZHA_CHANNEL_READS_PER_REQ + len(supported_attributes) / ZHA_CLUSTER_HANDLER_READS_PER_REQ ) read_attrs = { a for call in cluster.read_attributes.call_args_list for a in call[0][0] diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 7a24daaa3ba..720cfaaac9b 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -1,7 +1,10 @@ """Test ZHA WebSocket API.""" +from __future__ import annotations + from binascii import unhexlify from copy import deepcopy -from unittest.mock import AsyncMock, patch +from typing import TYPE_CHECKING +from unittest.mock import ANY, AsyncMock, call, patch import pytest import voluptuous as vol @@ -24,8 +27,6 @@ from homeassistant.components.zha.core.const import ( ATTR_NEIGHBORS, ATTR_QUIRK_APPLIED, CLUSTER_TYPE_IN, - DATA_ZHA, - DATA_ZHA_GATEWAY, EZSP_OVERWRITE_EUI64, GROUP_ID, GROUP_IDS, @@ -59,6 +60,9 @@ from tests.common import MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + @pytest.fixture(autouse=True) def required_platform_only(): @@ -282,15 +286,17 @@ async def test_get_zha_config_with_alarm( assert configuration == BASE_CUSTOM_CONFIGURATION -async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: +async def test_update_zha_config( + zha_client, app_controller: ControllerApplication +) -> None: """Test updating ZHA custom configuration.""" - configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 with patch( "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, + return_value=app_controller, ): await zha_client.send_json( {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} @@ -463,14 +469,12 @@ async def test_remove_group(zha_client) -> None: @pytest.fixture -async def app_controller(hass, setup_zha): +async def app_controller( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> ControllerApplication: """Fixture for zigpy Application Controller.""" await setup_zha() - controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller - p1 = patch.object(controller, "permit") - p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) - with p1, p2: - yield controller + return zigpy_app_controller @pytest.mark.parametrize( @@ -492,7 +496,7 @@ async def app_controller(hass, setup_zha): ) async def test_permit_ha12( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, duration, @@ -532,7 +536,7 @@ IC_TEST_PARAMS = ( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) async def test_permit_with_install_code( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, src_ieee, @@ -587,7 +591,10 @@ IC_FAIL_PARAMS = ( @pytest.mark.parametrize("params", IC_FAIL_PARAMS) async def test_permit_with_install_code_fail( - hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params + hass: HomeAssistant, + app_controller: ControllerApplication, + hass_admin_user: MockUser, + params, ) -> None: """Test permit service with install code.""" @@ -626,7 +633,7 @@ IC_QR_CODE_TEST_PARAMS = ( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) async def test_permit_with_qr_code( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, src_ieee, @@ -646,7 +653,7 @@ async def test_permit_with_qr_code( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) async def test_ws_permit_with_qr_code( - app_controller, zha_client, params, src_ieee, code + app_controller: ControllerApplication, zha_client, params, src_ieee, code ) -> None: """Test permit service with install code from qr code.""" @@ -668,7 +675,7 @@ async def test_ws_permit_with_qr_code( @pytest.mark.parametrize("params", IC_FAIL_PARAMS) async def test_ws_permit_with_install_code_fail( - app_controller, zha_client, params + app_controller: ControllerApplication, zha_client, params ) -> None: """Test permit ws service with install code.""" @@ -703,7 +710,7 @@ async def test_ws_permit_with_install_code_fail( ), ) async def test_ws_permit_ha12( - app_controller, zha_client, params, duration, node + app_controller: ControllerApplication, zha_client, params, duration, node ) -> None: """Test permit ws service.""" @@ -722,7 +729,9 @@ async def test_ws_permit_ha12( assert app_controller.permit_with_key.call_count == 0 -async def test_get_network_settings(app_controller, zha_client) -> None: +async def test_get_network_settings( + app_controller: ControllerApplication, zha_client +) -> None: """Test current network settings are returned.""" await app_controller.backups.create_backup() @@ -737,7 +746,9 @@ async def test_get_network_settings(app_controller, zha_client) -> None: assert "network_info" in msg["result"]["settings"] -async def test_list_network_backups(app_controller, zha_client) -> None: +async def test_list_network_backups( + app_controller: ControllerApplication, zha_client +) -> None: """Test backups are serialized.""" await app_controller.backups.create_backup() @@ -751,7 +762,9 @@ async def test_list_network_backups(app_controller, zha_client) -> None: assert "network_info" in msg["result"][0] -async def test_create_network_backup(app_controller, zha_client) -> None: +async def test_create_network_backup( + app_controller: ControllerApplication, zha_client +) -> None: """Test creating backup.""" assert not app_controller.backups.backups @@ -765,7 +778,9 @@ async def test_create_network_backup(app_controller, zha_client) -> None: assert "backup" in msg["result"] and "is_complete" in msg["result"] -async def test_restore_network_backup_success(app_controller, zha_client) -> None: +async def test_restore_network_backup_success( + app_controller: ControllerApplication, zha_client +) -> None: """Test successfully restoring a backup.""" backup = zigpy.backups.NetworkBackup() @@ -789,7 +804,7 @@ async def test_restore_network_backup_success(app_controller, zha_client) -> Non async def test_restore_network_backup_force_write_eui64( - app_controller, zha_client + app_controller: ControllerApplication, zha_client ) -> None: """Test successfully restoring a backup.""" @@ -821,7 +836,9 @@ async def test_restore_network_backup_force_write_eui64( @patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) -async def test_restore_network_backup_failure(app_controller, zha_client) -> None: +async def test_restore_network_backup_failure( + app_controller: ControllerApplication, zha_client +) -> None: """Test successfully restoring a backup.""" with patch.object( @@ -840,3 +857,29 @@ async def test_restore_network_backup_failure(app_controller, zha_client) -> Non assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + + +@pytest.mark.parametrize("new_channel", ["auto", 15]) +async def test_websocket_change_channel( + new_channel: int | str, app_controller: ControllerApplication, zha_client +) -> None: + """Test websocket API to migrate the network to a new channel.""" + + with patch( + "homeassistant.components.zha.websocket_api.async_change_channel", + autospec=True, + ) as change_channel_mock: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/change_channel", + "new_channel": new_channel, + } + ) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + change_channel_mock.mock_calls == [call(ANY, new_channel)] diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 0b27f1ead16..4ccf7323148 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -10,16 +10,42 @@ from zigpy.const import ( SIG_MODEL, SIG_NODE_DESC, ) +from zigpy.profiles import zha, zll +from zigpy.types import Bool, uint8_t +from zigpy.zcl.clusters.closures import DoorLock +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + LevelControl, + MultistateInput, + OnOff, + Ota, + PowerConfiguration, + Scenes, +) +from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.measurement import ( + IlluminanceMeasurement, + OccupancySensing, + TemperatureMeasurement, +) -DEV_SIG_CHANNELS = "channels" +DEV_SIG_CLUSTER_HANDLERS = "cluster_handlers" DEV_SIG_DEV_NO = "device_no" -DEV_SIG_ENTITIES = "entities" DEV_SIG_ENT_MAP = "entity_map" DEV_SIG_ENT_MAP_CLASS = "entity_class" DEV_SIG_ENT_MAP_ID = "entity_id" DEV_SIG_EP_ID = "endpoint_id" -DEV_SIG_EVT_CHANNELS = "event_channels" +DEV_SIG_EVT_CLUSTER_HANDLERS = "event_cluster_handlers" DEV_SIG_ZHA_QUIRK = "zha_quirk" +DEV_SIG_ATTRIBUTES = "attributes" + + +PROFILE_ID = SIG_EP_PROFILE +DEVICE_TYPE = SIG_EP_TYPE +INPUT_CLUSTERS = SIG_EP_INPUT +OUTPUT_CLUSTERS = SIG_EP_OUTPUT DEVICES = [ { @@ -36,25 +62,20 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], - DEV_SIG_ENTITIES: [ - "button.adurolight_adurolight_ncc_identify", - "sensor.adurolight_adurolight_ncc_rssi", - "sensor.adurolight_adurolight_ncc_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_lqi", }, @@ -74,43 +95,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["5:0x0019"], - DEV_SIG_ENTITIES: [ - "button.bosch_isw_zpr1_wp13_identify", - "sensor.bosch_isw_zpr1_wp13_battery", - "sensor.bosch_isw_zpr1_wp13_temperature", - "binary_sensor.bosch_isw_zpr1_wp13_iaszone", - "sensor.bosch_isw_zpr1_wp13_rssi", - "sensor.bosch_isw_zpr1_wp13_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["5:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_iaszone", }, ("button", "00:11:22:33:44:55:66:77-5-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-5-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_battery", }, ("sensor", "00:11:22:33:44:55:66:77-5-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-5-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-5-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_lqi", }, @@ -130,31 +143,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3130_identify", - "sensor.centralite_3130_battery", - "sensor.centralite_3130_rssi", - "sensor.centralite_3130_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_lqi", }, @@ -174,79 +181,65 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3210_l_identify", - "sensor.centralite_3210_l_active_power", - "sensor.centralite_3210_l_apparent_power", - "sensor.centralite_3210_l_rms_current", - "sensor.centralite_3210_l_rms_voltage", - "sensor.centralite_3210_l_ac_frequency", - "sensor.centralite_3210_l_power_factor", - "sensor.centralite_3210_l_instantaneous_demand", - "sensor.centralite_3210_l_summation_delivered", - "switch.centralite_3210_l_switch", - "sensor.centralite_3210_l_rssi", - "sensor.centralite_3210_l_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.centralite_3210_l_switch", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_lqi", }, @@ -266,43 +259,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3310_s_identify", - "sensor.centralite_3310_s_battery", - "sensor.centralite_3310_s_temperature", - "sensor.centralite_3310_s_humidity", - "sensor.centralite_3310_s_rssi", - "sensor.centralite_3310_s_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-64581"): { - DEV_SIG_CHANNELS: ["manufacturer_specific"], + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_humidity", }, @@ -329,43 +314,35 @@ DEVICES = [ SIG_EP_PROFILE: 49887, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3315_s_identify", - "sensor.centralite_3315_s_battery", - "sensor.centralite_3315_s_temperature", - "binary_sensor.centralite_3315_s_iaszone", - "sensor.centralite_3315_s_rssi", - "sensor.centralite_3315_s_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_lqi", }, @@ -392,43 +369,35 @@ DEVICES = [ SIG_EP_PROFILE: 49887, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3320_l_identify", - "sensor.centralite_3320_l_battery", - "sensor.centralite_3320_l_temperature", - "binary_sensor.centralite_3320_l_iaszone", - "sensor.centralite_3320_l_rssi", - "sensor.centralite_3320_l_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_lqi", }, @@ -455,43 +424,35 @@ DEVICES = [ SIG_EP_PROFILE: 49887, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_3326_l_identify", - "sensor.centralite_3326_l_battery", - "sensor.centralite_3326_l_temperature", - "binary_sensor.centralite_3326_l_iaszone", - "sensor.centralite_3326_l_rssi", - "sensor.centralite_3326_l_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_lqi", }, @@ -518,49 +479,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.centralite_motion_sensor_a_identify", - "sensor.centralite_motion_sensor_a_battery", - "sensor.centralite_motion_sensor_a_temperature", - "binary_sensor.centralite_motion_sensor_a_iaszone", - "binary_sensor.centralite_motion_sensor_a_occupancy", - "sensor.centralite_motion_sensor_a_rssi", - "sensor.centralite_motion_sensor_a_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { - DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_CLUSTER_HANDLERS: ["occupancy"], DEV_SIG_ENT_MAP_CLASS: "Occupancy", DEV_SIG_ENT_MAP_ID: ( "binary_sensor.centralite_motion_sensor_a_occupancy" @@ -589,51 +541,43 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["4:0x0019"], - DEV_SIG_ENTITIES: [ - "button.climaxtechnology_psmp5_00_00_02_02tc_identify", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered", - "switch.climaxtechnology_psmp5_00_00_02_02tc_switch", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_rssi", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["4:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: ( "switch.climaxtechnology_psmp5_00_00_02_02tc_switch" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.climaxtechnology_psmp5_00_00_02_02tc_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: ( "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: ( "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", }, @@ -653,73 +597,62 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.climaxtechnology_sd8sc_00_00_03_12tc_identify", - "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_iaszone", - "sensor.climaxtechnology_sd8sc_00_00_03_12tc_rssi", - "sensor.climaxtechnology_sd8sc_00_00_03_12tc_lqi", - "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone", - "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level", - "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level", - "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe", - "siren.climaxtechnology_sd8sc_00_00_03_12tc_siren", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: ( "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_iaszone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.climaxtechnology_sd8sc_00_00_03_12tc_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_lqi", }, ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", DEV_SIG_ENT_MAP_ID: ( "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone" ), }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", DEV_SIG_ENT_MAP_ID: ( "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level" ), }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", DEV_SIG_ENT_MAP_ID: ( "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level" ), }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", DEV_SIG_ENT_MAP_ID: ( "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe" ), }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHASiren", DEV_SIG_ENT_MAP_ID: "siren.climaxtechnology_sd8sc_00_00_03_12tc_siren", }, @@ -739,35 +672,29 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.climaxtechnology_ws15_00_00_03_03tc_identify", - "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_iaszone", - "sensor.climaxtechnology_ws15_00_00_03_03tc_rssi", - "sensor.climaxtechnology_ws15_00_00_03_03tc_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: ( "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_iaszone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.climaxtechnology_ws15_00_00_03_03tc_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_lqi", }, @@ -794,31 +721,25 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.feibit_inc_co_fb56_zcw08ku1_1_identify", - "light.feibit_inc_co_fb56_zcw08ku1_1_light", - "sensor.feibit_inc_co_fb56_zcw08ku1_1_rssi", - "sensor.feibit_inc_co_fb56_zcw08ku1_1_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-11"): { - DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.feibit_inc_co_fb56_zcw08ku1_1_light", }, ("button", "00:11:22:33:44:55:66:77-11-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-11-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-11-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_lqi", }, @@ -838,67 +759,55 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.heiman_smokesensor_em_identify", - "sensor.heiman_smokesensor_em_battery", - "binary_sensor.heiman_smokesensor_em_iaszone", - "sensor.heiman_smokesensor_em_rssi", - "sensor.heiman_smokesensor_em_lqi", - "select.heiman_smokesensor_em_default_siren_tone", - "select.heiman_smokesensor_em_default_siren_level", - "select.heiman_smokesensor_em_default_strobe_level", - "select.heiman_smokesensor_em_default_strobe", - "siren.heiman_smokesensor_em_siren", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_lqi", }, ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHASiren", DEV_SIG_ENT_MAP_ID: "siren.heiman_smokesensor_em_siren", }, @@ -918,31 +827,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.heiman_co_v16_identify", - "binary_sensor.heiman_co_v16_iaszone", - "sensor.heiman_co_v16_rssi", - "sensor.heiman_co_v16_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_lqi", }, @@ -962,61 +865,50 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.heiman_warningdevice_identify", - "binary_sensor.heiman_warningdevice_iaszone", - "sensor.heiman_warningdevice_rssi", - "sensor.heiman_warningdevice_lqi", - "select.heiman_warningdevice_default_siren_tone", - "select.heiman_warningdevice_default_siren_level", - "select.heiman_warningdevice_default_strobe_level", - "select.heiman_warningdevice_default_strobe", - "siren.heiman_warningdevice_siren", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHASiren", DEV_SIG_ENT_MAP_ID: "siren.heiman_warningdevice_siren", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_lqi", }, @@ -1036,49 +928,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["6:0x0019"], - DEV_SIG_ENTITIES: [ - "button.hivehome_com_mot003_identify", - "sensor.hivehome_com_mot003_battery", - "sensor.hivehome_com_mot003_illuminance", - "sensor.hivehome_com_mot003_temperature", - "binary_sensor.hivehome_com_mot003_iaszone", - "sensor.hivehome_com_mot003_rssi", - "sensor.hivehome_com_mot003_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["6:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_iaszone", }, ("button", "00:11:22:33:44:55:66:77-6-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identify", }, ("sensor", "00:11:22:33:44:55:66:77-6-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_battery", }, ("sensor", "00:11:22:33:44:55:66:77-6-1024"): { - DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-6-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-6-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-6-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_lqi", }, @@ -1105,37 +988,31 @@ DEVICES = [ SIG_EP_PROFILE: 41440, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify", - "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_light", - "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_rssi", - "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: ( "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_light" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi" @@ -1157,37 +1034,31 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify", - "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_light", - "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_rssi", - "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: ( "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_light" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi" @@ -1209,37 +1080,31 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify", - "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_light", - "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_rssi", - "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: ( "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_light" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi" @@ -1261,37 +1126,31 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify", - "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_light", - "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_rssi", - "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: ( "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_light" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi" @@ -1313,37 +1172,31 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify", - "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_light", - "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_rssi", - "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: ( "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_light" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi" @@ -1365,35 +1218,29 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_control_outlet_identify", - "switch.ikea_of_sweden_tradfri_control_outlet_switch", - "sensor.ikea_of_sweden_tradfri_control_outlet_rssi", - "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: ( "switch.ikea_of_sweden_tradfri_control_outlet_switch" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_control_outlet_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", }, @@ -1413,41 +1260,34 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_motion_sensor_identify", - "sensor.ikea_of_sweden_tradfri_motion_sensor_battery", - "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion", - "sensor.ikea_of_sweden_tradfri_motion_sensor_rssi", - "sensor.ikea_of_sweden_tradfri_motion_sensor_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_motion_sensor_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_motion_sensor_battery" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Motion", DEV_SIG_ENT_MAP_ID: ( "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion" @@ -1469,35 +1309,29 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_on_off_switch_identify", - "sensor.ikea_of_sweden_tradfri_on_off_switch_battery", - "sensor.ikea_of_sweden_tradfri_on_off_switch_rssi", - "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_on_off_switch_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_on_off_switch_battery" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", }, @@ -1517,35 +1351,29 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_remote_control_identify", - "sensor.ikea_of_sweden_tradfri_remote_control_battery", - "sensor.ikea_of_sweden_tradfri_remote_control_rssi", - "sensor.ikea_of_sweden_tradfri_remote_control_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_remote_control_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_remote_control_battery" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_lqi", }, @@ -1572,29 +1400,24 @@ DEVICES = [ SIG_EP_PROFILE: 41440, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_signal_repeater_identify", - "sensor.ikea_of_sweden_tradfri_signal_repeater_rssi", - "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_signal_repeater_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_signal_repeater_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", }, @@ -1614,37 +1437,31 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_wireless_dimmer_identify", - "sensor.ikea_of_sweden_tradfri_wireless_dimmer_battery", - "sensor.ikea_of_sweden_tradfri_wireless_dimmer_rssi", - "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.ikea_of_sweden_tradfri_wireless_dimmer_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_wireless_dimmer_battery" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: ( "sensor.ikea_of_sweden_tradfri_wireless_dimmer_rssi" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", }, @@ -1671,43 +1488,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], - DEV_SIG_ENTITIES: [ - "button.jasco_products_45852_identify", - "sensor.jasco_products_45852_instantaneous_demand", - "sensor.jasco_products_45852_summation_delivered", - "light.jasco_products_45852_light", - "sensor.jasco_products_45852_rssi", - "sensor.jasco_products_45852_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_lqi", }, @@ -1734,43 +1543,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], - DEV_SIG_ENTITIES: [ - "button.jasco_products_45856_identify", - "light.jasco_products_45856_light", - "sensor.jasco_products_45856_instantaneous_demand", - "sensor.jasco_products_45856_summation_delivered", - "sensor.jasco_products_45856_rssi", - "sensor.jasco_products_45856_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_lqi", }, @@ -1797,43 +1598,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], - DEV_SIG_ENTITIES: [ - "button.jasco_products_45857_identify", - "light.jasco_products_45857_light", - "sensor.jasco_products_45857_instantaneous_demand", - "sensor.jasco_products_45857_summation_delivered", - "sensor.jasco_products_45857_rssi", - "sensor.jasco_products_45857_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_lqi", }, @@ -1853,49 +1646,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_610_mp_1_3_identify", - "sensor.keen_home_inc_sv02_610_mp_1_3_battery", - "sensor.keen_home_inc_sv02_610_mp_1_3_pressure", - "sensor.keen_home_inc_sv02_610_mp_1_3_temperature", - "cover.keen_home_inc_sv02_610_mp_1_3_keenvent", - "sensor.keen_home_inc_sv02_610_mp_1_3_rssi", - "sensor.keen_home_inc_sv02_610_mp_1_3_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", - DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_610_mp_1_3_keenvent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_610_mp_1_3_keen_vent", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], DEV_SIG_ENT_MAP_CLASS: "Pressure", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_lqi", }, @@ -1915,49 +1699,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_2_identify", - "sensor.keen_home_inc_sv02_612_mp_1_2_battery", - "sensor.keen_home_inc_sv02_612_mp_1_2_pressure", - "sensor.keen_home_inc_sv02_612_mp_1_2_temperature", - "cover.keen_home_inc_sv02_612_mp_1_2_keenvent", - "sensor.keen_home_inc_sv02_612_mp_1_2_rssi", - "sensor.keen_home_inc_sv02_612_mp_1_2_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", - DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_2_keenvent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_2_keen_vent", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], DEV_SIG_ENT_MAP_CLASS: "Pressure", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_lqi", }, @@ -1977,49 +1752,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_3_identify", - "sensor.keen_home_inc_sv02_612_mp_1_3_battery", - "sensor.keen_home_inc_sv02_612_mp_1_3_pressure", - "sensor.keen_home_inc_sv02_612_mp_1_3_temperature", - "cover.keen_home_inc_sv02_612_mp_1_3_keenvent", - "sensor.keen_home_inc_sv02_612_mp_1_3_rssi", - "sensor.keen_home_inc_sv02_612_mp_1_3_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", - DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_3_keenvent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_3_keen_vent", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], DEV_SIG_ENT_MAP_CLASS: "Pressure", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_lqi", }, @@ -2039,39 +1805,32 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.king_of_fans_inc_hbuniversalcfremote_identify", - "light.king_of_fans_inc_hbuniversalcfremote_light", - "fan.king_of_fans_inc_hbuniversalcfremote_fan", - "sensor.king_of_fans_inc_hbuniversalcfremote_rssi", - "sensor.king_of_fans_inc_hbuniversalcfremote_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.king_of_fans_inc_hbuniversalcfremote_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: ( "button.king_of_fans_inc_hbuniversalcfremote_identify" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_lqi", }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { - DEV_SIG_CHANNELS: ["fan"], + DEV_SIG_CLUSTER_HANDLERS: ["fan"], DEV_SIG_ENT_MAP_CLASS: "ZhaFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, @@ -2091,31 +1850,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], - DEV_SIG_ENTITIES: [ - "button.lds_zbt_cctswitch_d0001_identify", - "sensor.lds_zbt_cctswitch_d0001_battery", - "sensor.lds_zbt_cctswitch_d0001_rssi", - "sensor.lds_zbt_cctswitch_d0001_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_lqi", }, @@ -2135,31 +1888,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ledvance_a19_rgbw_identify", - "light.ledvance_a19_rgbw_light", - "sensor.ledvance_a19_rgbw_rssi", - "sensor.ledvance_a19_rgbw_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_a19_rgbw_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_lqi", }, @@ -2179,31 +1926,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ledvance_flex_rgbw_identify", - "light.ledvance_flex_rgbw_light", - "sensor.ledvance_flex_rgbw_rssi", - "sensor.ledvance_flex_rgbw_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_flex_rgbw_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_lqi", }, @@ -2223,31 +1964,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ledvance_plug_identify", - "switch.ledvance_plug_switch", - "sensor.ledvance_plug_rssi", - "sensor.ledvance_plug_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.ledvance_plug_switch", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_lqi", }, @@ -2267,31 +2002,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.ledvance_rt_rgbw_identify", - "light.ledvance_rt_rgbw_light", - "sensor.ledvance_rt_rgbw_rssi", - "sensor.ledvance_rt_rgbw_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_rt_rgbw_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_lqi", }, @@ -2332,81 +2061,57 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_plug_maus01_identify", - "sensor.lumi_lumi_plug_maus01_active_power", - "sensor.lumi_lumi_plug_maus01_apparent_power", - "sensor.lumi_lumi_plug_maus01_rms_current", - "sensor.lumi_lumi_plug_maus01_rms_voltage", - "sensor.lumi_lumi_plug_maus01_ac_frequency", - "sensor.lumi_lumi_plug_maus01_power_factor", - "binary_sensor.lumi_lumi_plug_maus01_binaryinput", - "switch.lumi_lumi_plug_maus01_switch", - "sensor.lumi_lumi_plug_maus01_rssi", - "sensor.lumi_lumi_plug_maus01_lqi", - "sensor.lumi_lumi_plug_maus01_device_temperature", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.lumi_lumi_plug_maus01_switch", }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { - DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", }, - ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_apparent_power", - }, - ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_current", - }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_voltage", }, - ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_ac_frequency", - }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { - DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_binaryinput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_binary_input", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_summation_delivered", }, }, }, @@ -2431,79 +2136,65 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_relay_c2acn01_identify", - "light.lumi_lumi_relay_c2acn01_light", - "light.lumi_lumi_relay_c2acn01_light_2", - "sensor.lumi_lumi_relay_c2acn01_active_power", - "sensor.lumi_lumi_relay_c2acn01_apparent_power", - "sensor.lumi_lumi_relay_c2acn01_rms_current", - "sensor.lumi_lumi_relay_c2acn01_rms_voltage", - "sensor.lumi_lumi_relay_c2acn01_ac_frequency", - "sensor.lumi_lumi_relay_c2acn01_power_factor", - "sensor.lumi_lumi_relay_c2acn01_rssi", - "sensor.lumi_lumi_relay_c2acn01_lqi", - "sensor.lumi_lumi_relay_c2acn01_device_temperature", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light", }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { - DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_lqi", }, ("light", "00:11:22:33:44:55:66:77-2"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light_2", }, @@ -2537,31 +2228,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b186acn01_identify", - "sensor.lumi_lumi_remote_b186acn01_battery", - "sensor.lumi_lumi_remote_b186acn01_rssi", - "sensor.lumi_lumi_remote_b186acn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_lqi", }, @@ -2595,31 +2280,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286acn01_identify", - "sensor.lumi_lumi_remote_b286acn01_battery", - "sensor.lumi_lumi_remote_b286acn01_rssi", - "sensor.lumi_lumi_remote_b286acn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_lqi", }, @@ -2674,25 +2353,25 @@ DEVICES = [ SIG_EP_PROFILE: -1, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286opcn01_identify", - "sensor.lumi_lumi_remote_b286opcn01_rssi", - "sensor.lumi_lumi_remote_b286opcn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identify", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_battery", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_lqi", }, @@ -2747,25 +2426,25 @@ DEVICES = [ SIG_EP_PROFILE: -1, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b486opcn01_identify", - "sensor.lumi_lumi_remote_b486opcn01_rssi", - "sensor.lumi_lumi_remote_b486opcn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identify", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_battery", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_lqi", }, @@ -2785,25 +2464,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identify", - "sensor.lumi_lumi_remote_b686opcn01_rssi", - "sensor.lumi_lumi_remote_b686opcn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_battery", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_lqi", }, @@ -2858,25 +2537,25 @@ DEVICES = [ SIG_EP_PROFILE: None, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identify", - "sensor.lumi_lumi_remote_b686opcn01_rssi", - "sensor.lumi_lumi_remote_b686opcn01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_battery", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_lqi", }, @@ -2896,31 +2575,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - DEV_SIG_ENTITIES: [ - "light.lumi_lumi_router_light", - "binary_sensor.lumi_lumi_router_opening", - "sensor.lumi_lumi_router_rssi", - "sensor.lumi_lumi_router_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", }, @@ -2940,31 +2613,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - DEV_SIG_ENTITIES: [ - "light.lumi_lumi_router_light", - "binary_sensor.lumi_lumi_router_opening", - "sensor.lumi_lumi_router_rssi", - "sensor.lumi_lumi_router_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", }, @@ -2984,31 +2651,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - DEV_SIG_ENTITIES: [ - "light.lumi_lumi_router_light", - "binary_sensor.lumi_lumi_router_opening", - "sensor.lumi_lumi_router_rssi", - "sensor.lumi_lumi_router_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", }, @@ -3028,31 +2689,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sen_ill_mgl01_identify", - "sensor.lumi_lumi_sen_ill_mgl01_illuminance", - "sensor.lumi_lumi_sen_ill_mgl01_rssi", - "sensor.lumi_lumi_sen_ill_mgl01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_battery", + }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_lqi", }, @@ -3086,31 +2746,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_86sw1_identify", - "sensor.lumi_lumi_sensor_86sw1_battery", - "sensor.lumi_lumi_sensor_86sw1_rssi", - "sensor.lumi_lumi_sensor_86sw1_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_lqi", }, @@ -3144,31 +2798,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_cube_aqgl01_identify", - "sensor.lumi_lumi_sensor_cube_aqgl01_battery", - "sensor.lumi_lumi_sensor_cube_aqgl01_rssi", - "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", }, @@ -3202,43 +2850,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_ht_identify", - "sensor.lumi_lumi_sensor_ht_battery", - "sensor.lumi_lumi_sensor_ht_temperature", - "sensor.lumi_lumi_sensor_ht_humidity", - "sensor.lumi_lumi_sensor_ht_rssi", - "sensor.lumi_lumi_sensor_ht_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_humidity", }, @@ -3258,37 +2898,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_identify", - "sensor.lumi_lumi_sensor_magnet_battery", - "binary_sensor.lumi_lumi_sensor_magnet_opening", - "sensor.lumi_lumi_sensor_magnet_rssi", - "sensor.lumi_lumi_sensor_magnet_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_opening", }, @@ -3308,37 +2941,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_aq2_identify", - "sensor.lumi_lumi_sensor_magnet_aq2_battery", - "binary_sensor.lumi_lumi_sensor_magnet_aq2_opening", - "sensor.lumi_lumi_sensor_magnet_aq2_rssi", - "sensor.lumi_lumi_sensor_magnet_aq2_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_aq2_opening", }, @@ -3358,51 +2984,54 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_motion_aq2_identify", - "sensor.lumi_lumi_sensor_motion_aq2_battery", - "sensor.lumi_lumi_sensor_motion_aq2_illuminance", - "binary_sensor.lumi_lumi_sensor_motion_aq2_occupancy", - "binary_sensor.lumi_lumi_sensor_motion_aq2_iaszone", - "sensor.lumi_lumi_sensor_motion_aq2_rssi", - "sensor.lumi_lumi_sensor_motion_aq2_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { - DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_CLUSTER_HANDLERS: ["occupancy"], DEV_SIG_ENT_MAP_CLASS: "Occupancy", DEV_SIG_ENT_MAP_ID: ( "binary_sensor.lumi_lumi_sensor_motion_aq2_occupancy" ), }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_motion", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_illuminance", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: ( + "sensor.lumi_lumi_sensor_motion_aq2_device_temperature" + ), + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_lqi", }, @@ -3422,37 +3051,37 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_smoke_identify", - "sensor.lumi_lumi_sensor_smoke_battery", - "binary_sensor.lumi_lumi_sensor_smoke_iaszone", - "sensor.lumi_lumi_sensor_smoke_rssi", - "sensor.lumi_lumi_sensor_smoke_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_smoke", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_battery", }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: ( + "sensor.lumi_lumi_sensor_smoke_device_temperature" + ), + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_lqi", }, @@ -3472,31 +3101,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_switch_identify", - "sensor.lumi_lumi_sensor_switch_battery", - "sensor.lumi_lumi_sensor_switch_rssi", - "sensor.lumi_lumi_sensor_switch_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_lqi", }, @@ -3516,25 +3139,20 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - DEV_SIG_ENTITIES: [ - "sensor.lumi_lumi_sensor_switch_aq2_battery", - "sensor.lumi_lumi_sensor_switch_aq2_rssi", - "sensor.lumi_lumi_sensor_switch_aq2_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_lqi", }, @@ -3554,25 +3172,20 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - DEV_SIG_ENTITIES: [ - "sensor.lumi_lumi_sensor_switch_aq3_battery", - "sensor.lumi_lumi_sensor_switch_aq3_rssi", - "sensor.lumi_lumi_sensor_switch_aq3_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_lqi", }, @@ -3592,45 +3205,37 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_wleak_aq1_identify", - "sensor.lumi_lumi_sensor_wleak_aq1_battery", - "binary_sensor.lumi_lumi_sensor_wleak_aq1_iaszone", - "sensor.lumi_lumi_sensor_wleak_aq1_rssi", - "sensor.lumi_lumi_sensor_wleak_aq1_lqi", - "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_iaszone", }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { - DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", DEV_SIG_ENT_MAP_ID: ( "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_lqi", }, @@ -3643,59 +3248,66 @@ DEVICES = [ SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { - SIG_EP_TYPE: 10, - DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [0, 1, 3, 25, 257, 1280], - SIG_EP_OUTPUT: [0, 3, 4, 5, 25], - SIG_EP_PROFILE: 260, + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.DOOR_LOCK, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Ota.cluster_id, + DoorLock.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + Ota.cluster_id, + DoorLock.cluster_id, + ], }, 2: { - SIG_EP_TYPE: 24322, - DEV_SIG_EP_ID: 2, - SIG_EP_INPUT: [3], - SIG_EP_OUTPUT: [3, 4, 5, 18], - SIG_EP_PROFILE: 260, + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: 0x5F02, + INPUT_CLUSTERS: [Identify.cluster_id, MultistateInput.cluster_id], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + MultistateInput.cluster_id, + ], }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_vibration_aq1_identify", - "sensor.lumi_lumi_vibration_aq1_battery", - "binary_sensor.lumi_lumi_vibration_aq1_iaszone", - "lock.lumi_lumi_vibration_aq1_doorlock", - "sensor.lumi_lumi_vibration_aq1_rssi", - "sensor.lumi_lumi_vibration_aq1_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_vibration", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_lqi", }, - ("lock", "00:11:22:33:44:55:66:77-1-257"): { - DEV_SIG_CHANNELS: ["door_lock"], - DEV_SIG_ENT_MAP_CLASS: "ZhaDoorLock", - DEV_SIG_ENT_MAP_ID: "lock.lumi_lumi_vibration_aq1_doorlock", + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_device_temperature", }, }, }, @@ -3713,49 +3325,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.lumi_lumi_weather_identify", - "sensor.lumi_lumi_weather_battery", - "sensor.lumi_lumi_weather_pressure", - "sensor.lumi_lumi_weather_temperature", - "sensor.lumi_lumi_weather_humidity", - "sensor.lumi_lumi_weather_rssi", - "sensor.lumi_lumi_weather_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], DEV_SIG_ENT_MAP_CLASS: "Pressure", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_pressure", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { - DEV_SIG_CHANNELS: ["humidity"], + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_humidity", }, @@ -3775,37 +3378,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.nyce_3010_identify", - "sensor.nyce_3010_battery", - "binary_sensor.nyce_3010_iaszone", - "sensor.nyce_3010_rssi", - "sensor.nyce_3010_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_lqi", }, @@ -3825,37 +3421,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.nyce_3014_identify", - "sensor.nyce_3014_battery", - "binary_sensor.nyce_3014_iaszone", - "sensor.nyce_3014_rssi", - "sensor.nyce_3014_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_lqi", }, @@ -3882,8 +3471,7 @@ DEVICES = [ SIG_EP_PROFILE: 41440, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: ["1:0x0019"], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: {}, }, { @@ -3900,8 +3488,7 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: {}, }, { @@ -3918,31 +3505,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - DEV_SIG_ENTITIES: [ - "button.osram_lightify_a19_rgbw_identify", - "light.osram_lightify_a19_rgbw_light", - "sensor.osram_lightify_a19_rgbw_rssi", - "sensor.osram_lightify_a19_rgbw_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_a19_rgbw_light", }, ("button", "00:11:22:33:44:55:66:77-3-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_lqi", }, @@ -3962,31 +3543,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.osram_lightify_dimming_switch_identify", - "sensor.osram_lightify_dimming_switch_battery", - "sensor.osram_lightify_dimming_switch_rssi", - "sensor.osram_lightify_dimming_switch_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_lqi", }, @@ -4006,31 +3581,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - DEV_SIG_ENTITIES: [ - "button.osram_lightify_flex_rgbw_identify", - "light.osram_lightify_flex_rgbw_light", - "sensor.osram_lightify_flex_rgbw_rssi", - "sensor.osram_lightify_flex_rgbw_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_flex_rgbw_light", }, ("button", "00:11:22:33:44:55:66:77-3-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_lqi", }, @@ -4050,79 +3619,67 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - DEV_SIG_ENTITIES: [ - "button.osram_lightify_rt_tunable_white_identify", - "light.osram_lightify_rt_tunable_white_light", - "sensor.osram_lightify_rt_tunable_white_active_power", - "sensor.osram_lightify_rt_tunable_white_apparent_power", - "sensor.osram_lightify_rt_tunable_white_rms_current", - "sensor.osram_lightify_rt_tunable_white_rms_voltage", - "sensor.osram_lightify_rt_tunable_white_ac_frequency", - "sensor.osram_lightify_rt_tunable_white_power_factor", - "sensor.osram_lightify_rt_tunable_white_rssi", - "sensor.osram_lightify_rt_tunable_white_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_rt_tunable_white_light", }, ("button", "00:11:22:33:44:55:66:77-3-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_active_power" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_apparent_power" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_rms_current" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_rms_voltage" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_ac_frequency" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_power_factor" ), }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_lqi", }, @@ -4142,67 +3699,25 @@ DEVICES = [ SIG_EP_PROFILE: 49246, }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - DEV_SIG_ENTITIES: [ - "button.osram_plug_01_identify", - "sensor.osram_plug_01_active_power", - "sensor.osram_plug_01_apparent_power", - "sensor.osram_plug_01_rms_current", - "sensor.osram_plug_01_rms_voltage", - "sensor.osram_plug_01_ac_frequency", - "sensor.osram_plug_01_power_factor", - "switch.osram_plug_01_switch", - "sensor.osram_plug_01_rssi", - "sensor.osram_plug_01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.osram_plug_01_switch", }, ("button", "00:11:22:33:44:55:66:77-3-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identify", }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_active_power", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_apparent_power", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_current", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_voltage", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_ac_frequency", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_power_factor", - }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_lqi", }, @@ -4257,7 +3772,7 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [ + DEV_SIG_EVT_CLUSTER_HANDLERS: [ "1:0x0005", "1:0x0006", "1:0x0008", @@ -4284,24 +3799,19 @@ DEVICES = [ "6:0x0008", "6:0x0300", ], - DEV_SIG_ENTITIES: [ - "sensor.osram_switch_4x_lightify_battery", - "sensor.osram_switch_4x_lightify_rssi", - "sensor.osram_switch_4x_lightify_lqi", - ], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_lqi", }, @@ -4328,37 +3838,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], - DEV_SIG_ENTITIES: [ - "button.philips_rwl020_identify", - "sensor.philips_rwl020_battery", - "binary_sensor.philips_rwl020_binaryinput", - "sensor.philips_rwl020_rssi", - "sensor.philips_rwl020_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { - DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_binaryinput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_binary_input", }, ("button", "00:11:22:33:44:55:66:77-2-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identify", }, ("sensor", "00:11:22:33:44:55:66:77-2-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_battery", }, @@ -4378,43 +3881,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.samjin_button_identify", - "sensor.samjin_button_battery", - "sensor.samjin_button_temperature", - "binary_sensor.samjin_button_iaszone", - "sensor.samjin_button_rssi", - "sensor.samjin_button_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.samjin_button_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_lqi", }, @@ -4434,43 +3929,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.samjin_multi_identify", - "sensor.samjin_multi_battery", - "sensor.samjin_multi_temperature", - "binary_sensor.samjin_multi_iaszone", - "sensor.samjin_multi_rssi", - "sensor.samjin_multi_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identify", }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { + DEV_SIG_CLUSTER_HANDLERS: ["accelerometer"], + DEV_SIG_ENT_MAP_CLASS: "Accelerometer", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_accelerometer", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_lqi", }, @@ -4490,43 +3982,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.samjin_water_identify", - "sensor.samjin_water_battery", - "sensor.samjin_water_temperature", - "binary_sensor.samjin_water_iaszone", - "sensor.samjin_water_rssi", - "sensor.samjin_water_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.samjin_water_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_lqi", }, @@ -4546,67 +4030,55 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.securifi_ltd_unk_model_identify", - "sensor.securifi_ltd_unk_model_active_power", - "sensor.securifi_ltd_unk_model_apparent_power", - "sensor.securifi_ltd_unk_model_rms_current", - "sensor.securifi_ltd_unk_model_rms_voltage", - "sensor.securifi_ltd_unk_model_ac_frequency", - "sensor.securifi_ltd_unk_model_power_factor", - "switch.securifi_ltd_unk_model_switch", - "sensor.securifi_ltd_unk_model_rssi", - "sensor.securifi_ltd_unk_model_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_lqi", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_switch", }, @@ -4626,43 +4098,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_dws04n_sf_identify", - "sensor.sercomm_corp_sz_dws04n_sf_battery", - "sensor.sercomm_corp_sz_dws04n_sf_temperature", - "binary_sensor.sercomm_corp_sz_dws04n_sf_iaszone", - "sensor.sercomm_corp_sz_dws04n_sf_rssi", - "sensor.sercomm_corp_sz_dws04n_sf_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_lqi", }, @@ -4689,79 +4153,65 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], - DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_esw01_identify", - "sensor.sercomm_corp_sz_esw01_active_power", - "sensor.sercomm_corp_sz_esw01_apparent_power", - "sensor.sercomm_corp_sz_esw01_rms_current", - "sensor.sercomm_corp_sz_esw01_rms_voltage", - "sensor.sercomm_corp_sz_esw01_ac_frequency", - "sensor.sercomm_corp_sz_esw01_power_factor", - "sensor.sercomm_corp_sz_esw01_instantaneous_demand", - "sensor.sercomm_corp_sz_esw01_summation_delivered", - "light.sercomm_corp_sz_esw01_light", - "sensor.sercomm_corp_sz_esw01_rssi", - "sensor.sercomm_corp_sz_esw01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.sercomm_corp_sz_esw01_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_lqi", }, @@ -4781,49 +4231,40 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_pir04_identify", - "sensor.sercomm_corp_sz_pir04_battery", - "sensor.sercomm_corp_sz_pir04_illuminance", - "sensor.sercomm_corp_sz_pir04_temperature", - "binary_sensor.sercomm_corp_sz_pir04_iaszone", - "sensor.sercomm_corp_sz_pir04_rssi", - "sensor.sercomm_corp_sz_pir04_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { - DEV_SIG_CHANNELS: ["illuminance"], + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_illuminance", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_lqi", }, @@ -4843,69 +4284,57 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sinope_technologies_rm3250zb_identify", - "sensor.sinope_technologies_rm3250zb_active_power", - "sensor.sinope_technologies_rm3250zb_apparent_power", - "sensor.sinope_technologies_rm3250zb_rms_current", - "sensor.sinope_technologies_rm3250zb_rms_voltage", - "sensor.sinope_technologies_rm3250zb_ac_frequency", - "sensor.sinope_technologies_rm3250zb_power_factor", - "switch.sinope_technologies_rm3250zb_switch", - "sensor.sinope_technologies_rm3250zb_rssi", - "sensor.sinope_technologies_rm3250zb_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: ( "sensor.sinope_technologies_rm3250zb_apparent_power" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_lqi", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_switch", }, @@ -4932,81 +4361,70 @@ DEVICES = [ SIG_EP_PROFILE: 49757, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1123zb_identify", - "sensor.sinope_technologies_th1123zb_active_power", - "sensor.sinope_technologies_th1123zb_apparent_power", - "sensor.sinope_technologies_th1123zb_rms_current", - "sensor.sinope_technologies_th1123zb_rms_voltage", - "sensor.sinope_technologies_th1123zb_ac_frequency", - "sensor.sinope_technologies_th1123zb_power_factor", - "sensor.sinope_technologies_th1123zb_temperature", - "sensor.sinope_technologies_th1123zb_hvac_action", - "climate.sinope_technologies_th1123zb_thermostat", - "sensor.sinope_technologies_th1123zb_rssi", - "sensor.sinope_technologies_th1123zb_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["thermostat"], - DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_CLUSTER_HANDLERS: [ + "thermostat", + "sinope_manufacturer_specific", + ], + DEV_SIG_ENT_MAP_CLASS: "SinopeTechnologiesThermostat", DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1123zb_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: ( "sensor.sinope_technologies_th1123zb_apparent_power" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { - DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", }, @@ -5033,81 +4451,70 @@ DEVICES = [ SIG_EP_PROFILE: 49757, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1124zb_identify", - "sensor.sinope_technologies_th1124zb_active_power", - "sensor.sinope_technologies_th1124zb_apparent_power", - "sensor.sinope_technologies_th1124zb_rms_current", - "sensor.sinope_technologies_th1124zb_rms_voltage", - "sensor.sinope_technologies_th1124zb_ac_frequency", - "sensor.sinope_technologies_th1124zb_power_factor", - "sensor.sinope_technologies_th1124zb_temperature", - "sensor.sinope_technologies_th1124zb_hvac_action", - "climate.sinope_technologies_th1124zb_thermostat", - "sensor.sinope_technologies_th1124zb_rssi", - "sensor.sinope_technologies_th1124zb_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["thermostat"], - DEV_SIG_ENT_MAP_CLASS: "Thermostat", + DEV_SIG_CLUSTER_HANDLERS: [ + "thermostat", + "sinope_manufacturer_specific", + ], + DEV_SIG_ENT_MAP_CLASS: "SinopeTechnologiesThermostat", DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: ( "sensor.sinope_technologies_th1124zb_apparent_power" ), }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { - DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", }, @@ -5127,73 +4534,60 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.smartthings_outletv4_identify", - "sensor.smartthings_outletv4_active_power", - "sensor.smartthings_outletv4_apparent_power", - "sensor.smartthings_outletv4_rms_current", - "sensor.smartthings_outletv4_rms_voltage", - "sensor.smartthings_outletv4_ac_frequency", - "sensor.smartthings_outletv4_power_factor", - "binary_sensor.smartthings_outletv4_binaryinput", - "switch.smartthings_outletv4_switch", - "sensor.smartthings_outletv4_rssi", - "sensor.smartthings_outletv4_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { - DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_binaryinput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_binary_input", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CHANNELS: ["electrical_measurement"], + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_lqi", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_switch", }, @@ -5213,37 +4607,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.smartthings_tagv4_identify", - "device_tracker.smartthings_tagv4_devicescanner", - "binary_sensor.smartthings_tagv4_binaryinput", - "sensor.smartthings_tagv4_rssi", - "sensor.smartthings_tagv4_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("device_tracker", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "ZHADeviceScannerEntity", - DEV_SIG_ENT_MAP_ID: "device_tracker.smartthings_tagv4_devicescanner", + DEV_SIG_ENT_MAP_ID: "device_tracker.smartthings_tagv4_device_scanner", }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { - DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_binaryinput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_binary_input", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_lqi", }, @@ -5263,31 +4650,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss007z_identify", - "switch.third_reality_inc_3rss007z_switch", - "sensor.third_reality_inc_3rss007z_rssi", - "sensor.third_reality_inc_3rss007z_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_lqi", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_switch", }, @@ -5307,37 +4688,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss008z_identify", - "sensor.third_reality_inc_3rss008z_battery", - "switch.third_reality_inc_3rss008z_switch", - "sensor.third_reality_inc_3rss008z_rssi", - "sensor.third_reality_inc_3rss008z_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_lqi", }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_switch", }, @@ -5357,43 +4731,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.visonic_mct_340_e_identify", - "sensor.visonic_mct_340_e_battery", - "sensor.visonic_mct_340_e_temperature", - "binary_sensor.visonic_mct_340_e_iaszone", - "sensor.visonic_mct_340_e_rssi", - "sensor.visonic_mct_340_e_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_lqi", }, @@ -5413,43 +4779,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.zen_within_zen_01_identify", - "sensor.zen_within_zen_01_battery", - "sensor.zen_within_zen_01_hvac_action", - "climate.zen_within_zen_01_zenwithinthermostat", - "sensor.zen_within_zen_01_rssi", - "sensor.zen_within_zen_01_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["thermostat", "fan"], + DEV_SIG_CLUSTER_HANDLERS: ["thermostat", "fan"], DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", - DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_zenwithinthermostat", + DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_thermostat", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { - DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", }, @@ -5490,43 +4848,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "light.tyzb01_ns1ndbww_ts0004_light", - "light.tyzb01_ns1ndbww_ts0004_light_2", - "light.tyzb01_ns1ndbww_ts0004_light_3", - "light.tyzb01_ns1ndbww_ts0004_light_4", - "sensor.tyzb01_ns1ndbww_ts0004_rssi", - "sensor.tyzb01_ns1ndbww_ts0004_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_lqi", }, ("light", "00:11:22:33:44:55:66:77-2"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_2", }, ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_3", }, ("light", "00:11:22:33:44:55:66:77-4"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_4", }, @@ -5546,37 +4896,30 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.netvox_z308e3ed_identify", - "sensor.netvox_z308e3ed_battery", - "binary_sensor.netvox_z308e3ed_iaszone", - "sensor.netvox_z308e3ed_rssi", - "sensor.netvox_z308e3ed_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_iaszone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_lqi", }, @@ -5596,43 +4939,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sengled_e11_g13_identify", - "light.sengled_e11_g13_mintransitionlight", - "sensor.sengled_e11_g13_instantaneous_demand", - "sensor.sengled_e11_g13_summation_delivered", - "sensor.sengled_e11_g13_rssi", - "sensor.sengled_e11_g13_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", - DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_mintransitionlight", + DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_lqi", }, @@ -5652,43 +4987,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sengled_e12_n14_identify", - "light.sengled_e12_n14_mintransitionlight", - "sensor.sengled_e12_n14_instantaneous_demand", - "sensor.sengled_e12_n14_summation_delivered", - "sensor.sengled_e12_n14_rssi", - "sensor.sengled_e12_n14_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", - DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_mintransitionlight", + DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_lqi", }, @@ -5708,43 +5035,35 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - DEV_SIG_ENTITIES: [ - "button.sengled_z01_a19nae26_identify", - "light.sengled_z01_a19nae26_mintransitionlight", - "sensor.sengled_z01_a19nae26_instantaneous_demand", - "sensor.sengled_z01_a19nae26_summation_delivered", - "sensor.sengled_z01_a19nae26_rssi", - "sensor.sengled_z01_a19nae26_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", - DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_mintransitionlight", + DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_light", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_lqi", }, @@ -5764,31 +5083,25 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "button.unk_manufacturer_unk_model_identify", - "cover.unk_manufacturer_unk_model_shade", - "sensor.unk_manufacturer_unk_model_rssi", - "sensor.unk_manufacturer_unk_model_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("button", "00:11:22:33:44:55:66:77-1-3"): { - DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_CLUSTER_HANDLERS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off", "shade"], + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off", "shade"], DEV_SIG_ENT_MAP_CLASS: "Shade", DEV_SIG_ENT_MAP_ID: "cover.unk_manufacturer_unk_model_shade", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_lqi", }, @@ -5913,139 +5226,115 @@ DEVICES = [ SIG_EP_PROFILE: 49413, }, }, - DEV_SIG_EVT_CHANNELS: ["232:0x0008"], - DEV_SIG_ENTITIES: [ - "number.digi_xbee3_number", - "number.digi_xbee3_number_2", - "sensor.digi_xbee3_analoginput", - "sensor.digi_xbee3_analoginput_2", - "sensor.digi_xbee3_analoginput_3", - "sensor.digi_xbee3_analoginput_4", - "sensor.digi_xbee3_analoginput_5", - "switch.digi_xbee3_switch", - "switch.digi_xbee3_switch_2", - "switch.digi_xbee3_switch_3", - "switch.digi_xbee3_switch_4", - "switch.digi_xbee3_switch_5", - "switch.digi_xbee3_switch_6", - "switch.digi_xbee3_switch_7", - "switch.digi_xbee3_switch_8", - "switch.digi_xbee3_switch_9", - "switch.digi_xbee3_switch_10", - "switch.digi_xbee3_switch_11", - "switch.digi_xbee3_switch_12", - "switch.digi_xbee3_switch_13", - "switch.digi_xbee3_switch_14", - "switch.digi_xbee3_switch_15", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: ["232:0x0008"], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-208-12"): { - DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analoginput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input", }, ("switch", "00:11:22:33:44:55:66:77-208-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch", }, ("sensor", "00:11:22:33:44:55:66:77-209-12"): { - DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analoginput_2", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_2", }, ("switch", "00:11:22:33:44:55:66:77-209-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_2", }, ("sensor", "00:11:22:33:44:55:66:77-210-12"): { - DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analoginput_3", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_3", }, ("switch", "00:11:22:33:44:55:66:77-210-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_3", }, ("sensor", "00:11:22:33:44:55:66:77-211-12"): { - DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analoginput_4", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_4", }, ("switch", "00:11:22:33:44:55:66:77-211-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_4", }, ("switch", "00:11:22:33:44:55:66:77-212-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_5", }, ("switch", "00:11:22:33:44:55:66:77-213-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_6", }, ("switch", "00:11:22:33:44:55:66:77-214-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_7", }, ("sensor", "00:11:22:33:44:55:66:77-215-12"): { - DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analoginput_5", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_5", }, ("switch", "00:11:22:33:44:55:66:77-215-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_8", }, ("switch", "00:11:22:33:44:55:66:77-216-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_9", }, ("switch", "00:11:22:33:44:55:66:77-217-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_10", }, ("number", "00:11:22:33:44:55:66:77-218-13"): { - DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_output"], DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_number", }, ("switch", "00:11:22:33:44:55:66:77-218-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_11", }, ("switch", "00:11:22:33:44:55:66:77-219-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_12", }, ("number", "00:11:22:33:44:55:66:77-219-13"): { - DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_CLUSTER_HANDLERS: ["analog_output"], DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_number_2", }, ("switch", "00:11:22:33:44:55:66:77-220-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_13", }, ("switch", "00:11:22:33:44:55:66:77-221-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_14", }, ("switch", "00:11:22:33:44:55:66:77-222-6"): { - DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_15", }, @@ -6065,40 +5354,185 @@ DEVICES = [ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_EVT_CHANNELS: [], - DEV_SIG_ENTITIES: [ - "sensor.efektalab_ru_efekta_pws_battery", - "sensor.efektalab_ru_efekta_pws_soil_moisture", - "sensor.efektalab_ru_efekta_pws_temperature", - "sensor.efektalab_ru_efekta_pws_rssi", - "sensor.efektalab_ru_efekta_pws_lqi", - ], + DEV_SIG_EVT_CLUSTER_HANDLERS: [], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], + DEV_SIG_CLUSTER_HANDLERS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_battery", }, ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { - DEV_SIG_CHANNELS: ["soil_moisture"], + DEV_SIG_CLUSTER_HANDLERS: ["soil_moisture"], DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soil_moisture", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_temperature", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_rssi", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { - DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_lqi", }, }, }, + { + DEV_SIG_DEV_NO: 100, + SIG_MANUFACTURER: "Konke", + SIG_MODEL: "3AFE170100510001", + SIG_NODE_DESC: b"\x02@\x80\x02\x10RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: 260, + DEVICE_TYPE: zha.DeviceType.ON_OFF_OUTPUT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + ], + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.konke_3afe170100510001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 101, + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "SML001", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10Y?\x00\x00\x00?\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: zll.PROFILE_ID, + DEVICE_TYPE: zll.DeviceType.ON_OFF_SENSOR, + INPUT_CLUSTERS: [Basic.cluster_id], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Color.cluster_id, + ], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + IlluminanceMeasurement.cluster_id, + TemperatureMeasurement.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + ], + }, + }, + DEV_SIG_ATTRIBUTES: { + 2: { + "basic": { + "trigger_indicator": Bool(False), + }, + "philips_occupancy": { + "sensitivity": uint8_t(1), + }, + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [ + "1:0x0005", + "1:0x0006", + "1:0x0008", + "1:0x0300", + "2:0x0019", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-2-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.philips_sml001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_motion", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_illuminance", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueOccupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_occupancy", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_temperature", + }, + ("switch", "00:11:22:33:44:55:66:77-2-0-trigger_indicator"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "HueMotionTriggerIndicatorSwitch", + DEV_SIG_ENT_MAP_ID: "switch.philips_sml001_led_trigger_indicator", + }, + ("select", "00:11:22:33:44:55:66:77-2-1030-motion_sensitivity"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", + DEV_SIG_ENT_MAP_ID: "select.philips_sml001_hue_motion_sensitivity", + }, + }, + }, ] diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 82a12981d4d..9e2c5187218 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -60,3 +60,35 @@ async def test_ping_entity( ) is None ) + + +async def test_notification_idle_button( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: + """Test Notification idle button.""" + node = multisensor_6 + state = hass.states.get("button.multisensor_6_idle_cover_status") + assert state + assert state.state == "unknown" + assert state.attributes["friendly_name"] == "Multisensor 6 Idle Cover status" + + # Test successful idle call + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.multisensor_6_idle_cover_status", + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert args["command"] == "node.manually_idle_notification_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Cover status", + } diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2b2c9bb4137..73dd82d5f4b 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -342,6 +342,7 @@ async def test_supervisor_discovery( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -386,6 +387,7 @@ async def test_supervisor_discovery_cannot_connect( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -416,6 +418,7 @@ async def test_clean_discovery_on_user_create( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -486,6 +489,7 @@ async def test_abort_discovery_with_existing_entry( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -514,6 +518,7 @@ async def test_abort_hassio_discovery_with_existing_flow( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -536,6 +541,7 @@ async def test_abort_hassio_discovery_for_other_addon( }, name="Other Z-Wave JS", slug="other_addon", + uuid="1234", ), ) @@ -741,6 +747,7 @@ async def test_discovery_addon_not_running( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -825,6 +832,7 @@ async def test_discovery_addon_not_installed( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) @@ -912,6 +920,7 @@ async def test_abort_usb_discovery_with_existing_flow( config=ADDON_DISCOVERY_INFO, name="Z-Wave JS", slug=ADDON_SLUG, + uuid="1234", ), ) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 62513c3ad87..22cbe87d0b4 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -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) == 31 + assert len(entity_entries) == 36 # 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) == 18 + assert len(entity_entries) == 23 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None @@ -1363,6 +1363,7 @@ async def test_disabled_entity_on_value_removed( # 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" er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) await hass.async_block_till_done() @@ -1379,6 +1380,10 @@ async def test_disabled_entity_on_value_removed( assert state assert state.state != STATE_UNAVAILABLE + state = hass.states.get(idle_cover_status_button_entity) + assert state + assert state.state != STATE_UNAVAILABLE + # check for expected entities binary_cover_entity = "binary_sensor.4_in_1_sensor_tampering_product_cover_removed" state = hass.states.get(binary_cover_entity) @@ -1472,6 +1477,10 @@ async def test_disabled_entity_on_value_removed( assert state assert state.state == STATE_UNAVAILABLE + state = hass.states.get(idle_cover_status_button_entity) + assert state + assert state.state == STATE_UNAVAILABLE + # existing entities and the entities with removed values should be unavailable new_unavailable_entities = { state.entity_id @@ -1480,6 +1489,11 @@ async def test_disabled_entity_on_value_removed( } assert ( unavailable_entities - | {battery_level_entity, binary_cover_entity, sensor_cover_entity} + | { + battery_level_entity, + binary_cover_entity, + sensor_cover_entity, + idle_cover_status_button_entity, + } == new_unavailable_entities ) diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index efa8ec985e4..1c57eca2f26 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -1225,7 +1225,7 @@ async def test_multicast_set_value( blocking=True, ) - # Test that when a command fails we raise an exception + # Test that when a command is unsuccessful we raise an exception client.async_send_command.return_value = {"success": False} with pytest.raises(HomeAssistantError): @@ -1245,6 +1245,29 @@ async def test_multicast_set_value( blocking=True, ) + client.async_send_command.reset_mock() + + # Test that when we get an exception from the library we raise an exception + client.async_send_command.side_effect = FailedZWaveCommand("test", 12, "test") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: [ + CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, + ], + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: 2, + }, + blocking=True, + ) + + client.async_send_command.reset_mock() + # Create a fake node with a different home ID from a real node and patch it into # return of helper function to check the validation for two nodes having different # home IDs diff --git a/tests/conftest.py b/tests/conftest.py index 397d3d55b29..7184fac8189 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,7 +47,7 @@ from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE from homeassistant.config_entries import ConfigEntries, ConfigEntry from homeassistant.const import HASSIO_USER_NAME -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import CoreState, HassJob, HomeAssistant from homeassistant.helpers import ( area_registry as ar, config_entry_oauth2_flow, @@ -58,7 +58,7 @@ from homeassistant.helpers import ( recorder as recorder_helper, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component +from homeassistant.setup import BASE_PLATFORMS, async_setup_component from homeassistant.util import dt as dt_util, location from homeassistant.util.json import json_loads @@ -273,8 +273,12 @@ def expected_lingering_timers() -> bool: This should be removed when all lingering timers have been cleaned up. """ current_test = os.getenv("PYTEST_CURRENT_TEST") - if current_test and current_test.startswith("tests/components"): - # As a starting point, we ignore components + if ( + current_test + and current_test.startswith("tests/components/") + and current_test.split("/")[2] not in BASE_PLATFORMS + ): + # As a starting point, we ignore non-platform components return True return False @@ -341,6 +345,8 @@ def verify_cleanup( if not handle.cancelled(): if expected_lingering_timers: _LOGGER.warning("Lingering timer after test %r", handle) + elif handle._args and isinstance(job := handle._args[0], HassJob): + pytest.fail(f"Lingering timer after job {repr(job)}") else: pytest.fail(f"Lingering timer after test {repr(handle)}") handle.cancel() @@ -484,14 +490,24 @@ def hass_fixture_setup() -> list[bool]: @pytest.fixture -def hass( +def hass(_hass: HomeAssistant) -> HomeAssistant: + """Fixture to provide a test instance of Home Assistant.""" + # This wraps the async _hass fixture inside a sync fixture, to ensure + # the `hass` context variable is set in the execution context in which + # the test itself is executed + ha._cv_hass.set(_hass) + return _hass + + +@pytest.fixture +async def _hass( hass_fixture_setup: list[bool], event_loop: asyncio.AbstractEventLoop, load_registries: bool, hass_storage: dict[str, Any], request: pytest.FixtureRequest, -) -> Generator[HomeAssistant, None, None]: - """Fixture to provide a test instance of Home Assistant.""" +) -> AsyncGenerator[HomeAssistant, None]: + """Create a test instance of Home Assistant.""" loop = event_loop hass_fixture_setup.append(True) @@ -515,15 +531,23 @@ def hass( orig_exception_handler(loop, context) exceptions: list[Exception] = [] - hass = loop.run_until_complete(async_test_home_assistant(loop, load_registries)) - ha._cv_hass.set(hass) + hass = await async_test_home_assistant(loop, load_registries) orig_exception_handler = loop.get_exception_handler() loop.set_exception_handler(exc_handle) yield hass - loop.run_until_complete(hass.async_stop(force=True)) + # Config entries are not normally unloaded on HA shutdown. They are unloaded here + # to ensure that they could, and to help track lingering tasks and timers. + await asyncio.gather( + *( + config_entry.async_unload(hass) + for config_entry in hass.config_entries.async_entries() + ) + ) + + await hass.async_stop(force=True) # Restore timezone, it is set when creating the hass object dt_util.DEFAULT_TIME_ZONE = orig_tz @@ -635,29 +659,25 @@ def hass_owner_user( @pytest.fixture -def hass_admin_user( +async def hass_admin_user( hass: HomeAssistant, local_auth: homeassistant.HassAuthProvider ) -> MockUser: """Return a Home Assistant admin user.""" - admin_group = hass.loop.run_until_complete( - hass.auth.async_get_group(GROUP_ID_ADMIN) - ) + admin_group = await hass.auth.async_get_group(GROUP_ID_ADMIN) return MockUser(groups=[admin_group]).add_to_hass(hass) @pytest.fixture -def hass_read_only_user( +async def hass_read_only_user( hass: HomeAssistant, local_auth: homeassistant.HassAuthProvider ) -> MockUser: """Return a Home Assistant read only user.""" - read_only_group = hass.loop.run_until_complete( - hass.auth.async_get_group(GROUP_ID_READ_ONLY) - ) + read_only_group = await hass.auth.async_get_group(GROUP_ID_READ_ONLY) return MockUser(groups=[read_only_group]).add_to_hass(hass) @pytest.fixture -def hass_read_only_access_token( +async def hass_read_only_access_token( hass: HomeAssistant, hass_read_only_user: MockUser, local_auth: homeassistant.HassAuthProvider, @@ -672,37 +692,31 @@ def hass_read_only_access_token( ) hass_read_only_user.credentials.append(credential) - refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token( - hass_read_only_user, CLIENT_ID, credential=credential - ) + refresh_token = await hass.auth.async_create_refresh_token( + hass_read_only_user, CLIENT_ID, credential=credential ) return hass.auth.async_create_access_token(refresh_token) @pytest.fixture -def hass_supervisor_user( +async def hass_supervisor_user( hass: HomeAssistant, local_auth: homeassistant.HassAuthProvider ) -> MockUser: """Return the Home Assistant Supervisor user.""" - admin_group = hass.loop.run_until_complete( - hass.auth.async_get_group(GROUP_ID_ADMIN) - ) + admin_group = await hass.auth.async_get_group(GROUP_ID_ADMIN) return MockUser( name=HASSIO_USER_NAME, groups=[admin_group], system_generated=True ).add_to_hass(hass) @pytest.fixture -def hass_supervisor_access_token( +async def hass_supervisor_access_token( hass: HomeAssistant, hass_supervisor_user, local_auth: homeassistant.HassAuthProvider, ) -> str: """Return a Home Assistant Supervisor access token.""" - refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(hass_supervisor_user) - ) + refresh_token = await hass.auth.async_create_refresh_token(hass_supervisor_user) return hass.auth.async_create_access_token(refresh_token) @@ -721,12 +735,12 @@ def legacy_auth( @pytest.fixture -def local_auth(hass: HomeAssistant) -> homeassistant.HassAuthProvider: +async def local_auth(hass: HomeAssistant) -> homeassistant.HassAuthProvider: """Load local auth provider.""" prv = homeassistant.HassAuthProvider( hass, hass.auth._store, {"type": "homeassistant"} ) - hass.loop.run_until_complete(prv.async_initialize()) + await prv.async_initialize() hass.auth._providers[(prv.type, prv.id)] = prv return prv @@ -906,11 +920,11 @@ async def mqtt_mock( mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, - mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient, None]: """Fixture to mock MQTT component.""" with patch("homeassistant.components.mqtt.PLATFORMS", []): - return await mqtt_mock_entry_no_yaml_config() + return await mqtt_mock_entry() @asynccontextmanager @@ -967,9 +981,10 @@ async def _mqtt_mock_entry( nonlocal mock_mqtt_instance nonlocal real_mqtt_instance real_mqtt_instance = real_mqtt(*args, **kwargs) + spec = dir(real_mqtt_instance) + ["_mqttc"] mock_mqtt_instance = MqttMockHAClient( return_value=real_mqtt_instance, - spec_set=real_mqtt_instance, + spec_set=spec, wraps=real_mqtt_instance, ) return mock_mqtt_instance @@ -1042,12 +1057,12 @@ def mock_hass_config_yaml( @pytest.fixture -async def mqtt_mock_entry_no_yaml_config( +async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator, None]: - """Set up an MQTT config entry without MQTT yaml config.""" + """Set up an MQTT config entry.""" async def _async_setup_config_entry( hass: HomeAssistant, entry: ConfigEntry @@ -1067,30 +1082,6 @@ async def mqtt_mock_entry_no_yaml_config( yield _setup_mqtt_entry -@pytest.fixture -async def mqtt_mock_entry_with_yaml_config( - hass: HomeAssistant, - mqtt_client_mock: MqttMockPahoClient, - mqtt_config_entry_data: dict[str, Any] | None, -) -> AsyncGenerator[MqttMockHAClientGenerator, None]: - """Set up an MQTT config entry with MQTT yaml config.""" - - async def _async_do_not_setup_config_entry( - hass: HomeAssistant, entry: ConfigEntry - ) -> bool: - """Do nothing.""" - return True - - async def _setup_mqtt_entry() -> MqttMockHAClient: - """Set up the MQTT config entry.""" - return await mqtt_mock_entry(_async_do_not_setup_config_entry) - - async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data - ) as mqtt_mock_entry: - yield _setup_mqtt_entry - - @pytest.fixture(autouse=True) def mock_network() -> Generator[None, None, None]: """Mock network.""" diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 64c83757a7b..7969e02ab2f 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -82,7 +82,7 @@ class MockObservableCollection(collection.ObservableCollection): return entity_class.from_storage(config) -class MockStorageCollection(collection.StorageCollection): +class MockStorageCollection(collection.DictStorageCollection): """Mock storage collection.""" async def _process_create_data(self, data: dict) -> dict: @@ -96,9 +96,9 @@ class MockStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info["name"] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - return {**data, **update_data} + return {**item, **update_data} def test_id_manager() -> None: @@ -116,7 +116,7 @@ def test_id_manager() -> None: async def test_observable_collection() -> None: """Test observerable collection.""" - coll = collection.ObservableCollection(_LOGGER) + coll = collection.ObservableCollection(None) assert coll.async_items() == [] coll.data["bla"] = 1 assert coll.async_items() == [1] @@ -202,7 +202,7 @@ async def test_storage_collection(hass: HomeAssistant) -> None: } ) id_manager = collection.IDManager() - coll = MockStorageCollection(store, _LOGGER, id_manager) + coll = MockStorageCollection(store, id_manager) changes = track_changes(coll) await coll.async_load() @@ -257,7 +257,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity) await coll.notify_changes( @@ -297,7 +297,7 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) async_update_config_calls = [] async_remove_calls = [] @@ -364,7 +364,7 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) - """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) async_update_config_calls = [] async_remove_calls = [] @@ -434,9 +434,9 @@ async def test_storage_collection_websocket( ) -> None: """Test exposing a storage collection via websockets.""" store = storage.Store(hass, 1, "test-data") - coll = MockStorageCollection(store, _LOGGER) + coll = MockStorageCollection(store) changes = track_changes(coll) - collection.StorageCollectionWebsocket( + collection.DictStorageCollectionWebsocket( coll, "test_item/collection", "test_item", diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 23f6571e8bc..90d8030be79 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -355,7 +355,6 @@ async def test_webhook_config_flow_registers_webhook( assert result["data"]["webhook_id"] is not None -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook( hass: HomeAssistant, webhook_flow_conf: None ) -> None: @@ -411,7 +410,6 @@ async def test_webhook_create_cloudhook( assert result["require_restart"] is False -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook_aborts_not_connected( hass: HomeAssistant, webhook_flow_conf: None ) -> None: diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 4c25413d9fb..4d36679d538 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -1,20 +1,26 @@ """Tests for debounce.""" +import asyncio from datetime import timedelta +import logging from unittest.mock import AsyncMock +import pytest + from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.util.dt import utcnow from ..common import async_fire_time_changed +_LOGGER = logging.getLogger(__name__) + async def test_immediate_works(hass: HomeAssistant) -> None: """Test immediate works.""" calls = [] debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=True, function=AsyncMock(side_effect=lambda: calls.append(None)), @@ -68,7 +74,7 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: calls = [] debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=False, function=AsyncMock(side_effect=lambda: calls.append(None)), @@ -123,7 +129,7 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=True, function=one_function, @@ -174,3 +180,37 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non assert debouncer._execute_at_end_of_timer is False debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + + +async def test_shutdown(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test shutdown.""" + calls = [] + future = asyncio.Future() + + async def _func() -> None: + await future + calls.append(None) + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=False, + function=_func, + ) + + # Ensure shutdown during a run doesn't create a cooldown timer + hass.async_create_task(debouncer.async_call()) + await asyncio.sleep(0.01) + await debouncer.async_shutdown() + future.set_result(True) + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is None + + assert "Debouncer call ignored as shutdown has been requested." not in caplog.text + await debouncer.async_call() + assert "Debouncer call ignored as shutdown has been requested." in caplog.text + + assert len(calls) == 1 + assert debouncer._timer_task is None diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2b9a332e825..bb95860142d 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -536,7 +536,7 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" - await ent.async_update_ha_state() + ent.async_write_ha_state() assert len(hass.states.async_entity_ids()) == 1 await ent.async_remove() assert len(hass.states.async_entity_ids()) == 0 @@ -577,7 +577,7 @@ async def test_set_context(hass: HomeAssistant) -> None: ent.hass = hass ent.entity_id = "hello.world" ent.async_set_context(context) - await ent.async_update_ha_state() + ent.async_write_ha_state() assert hass.states.get("hello.world").context == context @@ -593,7 +593,7 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: ent.hass = hass ent.entity_id = "hello.world" ent.async_set_context(context) - await ent.async_update_ha_state() + ent.async_write_ha_state() assert hass.states.get("hello.world").context != context assert ent._context is None @@ -986,3 +986,27 @@ async def test_repr_using_stringify_state() -> None: entity = MyEntity(entity_id="test.test", available=False) assert str(entity) == "" + + +async def test_warn_using_async_update_ha_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we warn once when using async_update_ha_state without force_update.""" + ent = entity.Entity() + ent.hass = hass + ent.entity_id = "hello.world" + + # When forcing, it should not trigger the warning + caplog.clear() + await ent.async_update_ha_state(force_refresh=True) + assert "is using self.async_update_ha_state()" not in caplog.text + + # When not forcing, it should trigger the warning + caplog.clear() + await ent.async_update_ha_state() + assert "is using self.async_update_ha_state()" in caplog.text + + # When not forcing, it should not trigger the warning again + caplog.clear() + await ent.async_update_ha_state() + assert "is using self.async_update_ha_state()" not in caplog.text diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9888704702c..2141c286914 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -369,7 +369,7 @@ def test_filter_schema_include_exclude() -> None: assert not filt.empty_filter -def test_exlictly_included() -> None: +def test_explicitly_included() -> None: """Test if an entity is explicitly included.""" conf = { "include": { diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 2f08f6e95cc..9d90ef1b26c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3666,6 +3666,7 @@ async def test_track_sunset(hass: HomeAssistant) -> None: async def test_async_track_time_change(hass: HomeAssistant) -> None: """Test tracking time change.""" + none_runs = [] wildcard_runs = [] specific_runs = [] @@ -3678,12 +3679,17 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): - unsub = async_track_time_change( - hass, callback(lambda x: wildcard_runs.append(x)) - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) unsub_utc = async_track_utc_time_change( hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3691,6 +3697,7 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 1 + assert len(none_runs) == 1 async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 15, 999999, tzinfo=dt_util.UTC) @@ -3698,6 +3705,7 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 2 + assert len(none_runs) == 2 async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) @@ -3705,9 +3713,11 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 + assert len(none_runs) == 3 unsub() unsub_utc() + unsub_wildcard() async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) @@ -3715,6 +3725,7 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 + assert len(none_runs) == 3 async def test_periodic_task_minute(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 0a24eac38c6..3c04087e48d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -342,6 +342,23 @@ def test_area_selector_schema(schema, valid_selections, invalid_selections) -> N _test_selector("area", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ("23ouih2iu23ou2", "2j4hp3uy4p87wyrpiuhk34"), + (None, True, 1), + ), + ), +) +def test_assist_pipeline_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test assist pipeline selector.""" + _test_selector("assist_pipeline", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), ( @@ -431,7 +448,7 @@ def test_boolean_selector_schema(schema, valid_selections, invalid_selections) - def test_config_entry_selector_schema( schema, valid_selections, invalid_selections ) -> None: - """Test boolean selector.""" + """Test config entry selector.""" _test_selector("config_entry", schema, valid_selections, invalid_selections) @@ -748,6 +765,26 @@ def test_media_selector_schema(schema, valid_selections, invalid_selections) -> ) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ("nl", "fr"), + (None, True, 1), + ), + ( + {"languages": ["nl", "fr"]}, + ("nl", "fr"), + (None, True, 1, "de", "en"), + ), + ), +) +def test_language_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test language selector.""" + _test_selector("language", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), ( diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 214a349d603..e18758658d6 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime, timedelta +import json import logging import math import random @@ -10,6 +11,7 @@ from typing import Any from unittest.mock import patch from freezegun import freeze_time +import orjson import pytest import voluptuous as vol @@ -98,14 +100,14 @@ def assert_result_info( assert info.filter("invalid_entity_name.somewhere") == all_states if entities is not None: assert info.entities == frozenset(entities) - assert all([info.filter(entity) for entity in entities]) + assert all(info.filter(entity) for entity in entities) if not all_states: assert not info.filter("invalid_entity_name.somewhere") else: assert not info.entities if domains is not None: assert info.domains == frozenset(domains) - assert all([info.filter(domain + ".entity") for domain in domains]) + assert all(info.filter(domain + ".entity") for domain in domains) else: assert not hasattr(info, "_domains") @@ -1047,14 +1049,31 @@ def test_to_json(hass: HomeAssistant) -> None: ).async_render() assert actual_result == expected_result + expected_result = orjson.dumps({"Foo": "Bar"}, option=orjson.OPT_INDENT_2).decode() + actual_result = template.Template( + "{{ {'Foo': 'Bar'} | to_json(pretty_print=True) }}", hass + ).async_render(parse_result=False) + assert actual_result == expected_result -def test_to_json_string(hass: HomeAssistant) -> None: + expected_result = orjson.dumps( + {"Z": 26, "A": 1, "M": 13}, option=orjson.OPT_SORT_KEYS + ).decode() + actual_result = template.Template( + "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True) }}", hass + ).async_render(parse_result=False) + assert actual_result == expected_result + + with pytest.raises(TemplateError): + template.Template("{{ {'Foo': now()} | to_json }}", hass).async_render() + + +def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" # Note that we're not testing the actual json.loads and json.dumps methods, # only the filters, so we don't need to be exhaustive with our sample JSON. actual_value_ascii = template.Template( - "{{ 'Bar ҝ éèà' | to_json }}", hass + "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=True) }}", hass ).async_render() assert actual_value_ascii == '"Bar \\u049d \\u00e9\\u00e8\\u00e0"' actual_value = template.Template( @@ -1062,6 +1081,19 @@ def test_to_json_string(hass: HomeAssistant) -> None: ).async_render() assert actual_value == '"Bar ҝ éèà"' + expected_result = json.dumps({"Foo": "Bar"}, indent=2) + actual_result = template.Template( + "{{ {'Foo': 'Bar'} | to_json(pretty_print=True, ensure_ascii=True) }}", hass + ).async_render(parse_result=False) + assert actual_result == expected_result + + expected_result = json.dumps({"Z": 26, "A": 1, "M": 13}, sort_keys=True) + actual_result = template.Template( + "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True, ensure_ascii=True) }}", + hass, + ).async_render(parse_result=False) + assert actual_result == expected_result + def test_from_json(hass: HomeAssistant) -> None: """Test the JSON string to object filter.""" @@ -1170,32 +1202,28 @@ def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: ) assert ( template.Template( - "{{ (state_attr('test.object', 'objects') | min(attribute='%s'))['%s']}}" - % (attribute, attribute), + f"{{{{ (state_attr('test.object', 'objects') | min(attribute='{attribute}'))['{attribute}']}}}}", hass, ).async_render() == 1 ) assert ( template.Template( - "{{ (min(state_attr('test.object', 'objects'), attribute='%s'))['%s']}}" - % (attribute, attribute), + f"{{{{ (min(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", hass, ).async_render() == 1 ) assert ( template.Template( - "{{ (state_attr('test.object', 'objects') | max(attribute='%s'))['%s']}}" - % (attribute, attribute), + f"{{{{ (state_attr('test.object', 'objects') | max(attribute='{attribute}'))['{attribute}']}}}}", hass, ).async_render() == 3 ) assert ( template.Template( - "{{ (max(state_attr('test.object', 'objects'), attribute='%s'))['%s']}}" - % (attribute, attribute), + f"{{{{ (max(state_attr('test.object', 'objects'), attribute='{attribute}'))['{attribute}']}}}}", hass, ).async_render() == 3 @@ -2291,8 +2319,7 @@ def test_distance_function_with_2_coords(hass: HomeAssistant) -> None: _set_up_units(hass) assert ( template.Template( - '{{ distance("32.87336", "-117.22943", %s, %s) | round }}' - % (hass.config.latitude, hass.config.longitude), + f'{{{{ distance("32.87336", "-117.22943", {hass.config.latitude}, {hass.config.longitude}) | round }}}}', hass, ).async_render() == 187 @@ -3320,16 +3347,14 @@ def test_closest_function_to_coord(hass: HomeAssistant) -> None: ) tpl = template.Template( - '{{ closest("%s", %s, states.test_domain).entity_id }}' - % (hass.config.latitude + 0.3, hass.config.longitude + 0.3), + f'{{{{ closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3}, states.test_domain).entity_id }}}}', hass, ) assert tpl.async_render() == "test_domain.closest_zone" tpl = template.Template( - '{{ (states.test_domain | closest("%s", %s)).entity_id }}' - % (hass.config.latitude + 0.3, hass.config.longitude + 0.3), + f'{{{{ (states.test_domain | closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3})).entity_id }}}}', hass, ) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index fc6b7bcf757..35ae78834c5 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -116,6 +116,76 @@ async def test_async_refresh( assert updates == [2] +async def test_shutdown( + hass: HomeAssistant, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test async_shutdown for update coordinator.""" + assert crd.data is None + await crd.async_refresh() + assert crd.data == 1 + assert crd.last_update_success is True + # Make sure we didn't schedule a refresh because we have 0 listeners + assert crd._unsub_refresh is None + + updates = [] + + def update_callback(): + updates.append(crd.data) + + _ = crd.async_add_listener(update_callback) + await crd.async_refresh() + assert updates == [2] + assert crd._unsub_refresh is not None + + # Test shutdown through function + with patch.object(crd._debounced_refresh, "async_shutdown") as mock_shutdown: + await crd.async_shutdown() + + async_fire_time_changed(hass, utcnow() + crd.update_interval) + await hass.async_block_till_done() + + # Test we shutdown the debouncer and cleared the subscriptions + assert len(mock_shutdown.mock_calls) == 1 + assert crd._unsub_refresh is None + + await crd.async_refresh() + assert updates == [2] + + +async def test_shutdown_on_entry_unload( + hass: HomeAssistant, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test shutdown is requested on entry unload.""" + entry = MockConfigEntry() + config_entries.current_entry.set(entry) + + 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, + ) + + crd.async_add_listener(lambda: None) + assert crd._unsub_refresh is not None + assert not crd._shutdown_requested + + await entry._async_process_on_unload(hass) + + 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/test_bootstrap.py b/tests/test_bootstrap.py index cd0d7ef069e..26eef47273f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,6 +1,6 @@ """Test the bootstrapping.""" import asyncio -from collections.abc import Generator +from collections.abc import Generator, Iterable import glob import os from typing import Any @@ -10,12 +10,16 @@ import pytest from homeassistant import bootstrap, runner import homeassistant.config as config_util +from homeassistant.config_entries import HANDLERS, ConfigEntry from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration from .common import ( + MockConfigEntry, MockModule, MockPlatform, get_test_config_dir, @@ -551,7 +555,6 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( assert "Waiting on integrations to complete setup" in caplog.text -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_yaml( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -607,7 +610,6 @@ async def test_setup_hass_config_dir_nonexistent( ) -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_safe_mode( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -642,7 +644,6 @@ async def test_setup_hass_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_core_config( mock_hass_config: None, mock_enable_logging: Mock, @@ -681,7 +682,6 @@ async def test_setup_hass_invalid_core_config( } ], ) -@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_safe_mode_if_no_frontend( mock_hass_config: None, mock_enable_logging: Mock, @@ -829,3 +829,133 @@ async def test_bootstrap_empty_integrations( """Test setting up an empty integrations does not raise.""" await bootstrap.async_setup_multi_components(hass, set(), {}) await hass.async_block_till_done() + + +@pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_dependencies( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + integration: str, +) -> None: + """Test dependencies are set up correctly,.""" + + # Prepare MQTT config entry + @HANDLERS.register("mqtt") + class MockConfigFlow: + """Mock the MQTT config flow.""" + + VERSION = 1 + + entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) + entry.add_to_hass(hass) + + calls: list[str] = [] + assertions: list[bool] = [] + + async def async_mqtt_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Assert the mqtt config entry was set up.""" + calls.append("mqtt") + # assert the integration is not yet set up + assertions.append(hass.data["setup_done"][integration].is_set() is False) + assertions.append( + all( + dependency in hass.config.components + for dependency in integrations[integration]["dependencies"] + ) + ) + assertions.append(integration not in hass.config.components) + return True + + async def async_integration_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Assert the mqtt config entry was set up.""" + calls.append(integration) + # assert mqtt was already set up + assertions.append( + "mqtt" not in hass.data["setup_done"] + or hass.data["setup_done"]["mqtt"].is_set() + ) + assertions.append("mqtt" in hass.config.components) + return True + + mqtt_integration = mock_integration( + hass, + MockModule( + "mqtt", + async_setup_entry=async_mqtt_setup_entry, + dependencies=["file_upload", "http"], + ), + ) + + # We patch the _import platform method to avoid loading the platform module + # to avoid depending on non core components in the tests. + mqtt_integration._import_platform = Mock() + + integrations = { + "mqtt": { + "dependencies": {"file_upload", "http"}, + "integration": mqtt_integration, + }, + "mqtt_eventstream": { + "dependencies": {"mqtt"}, + "integration": mock_integration( + hass, + MockModule( + "mqtt_eventstream", + async_setup=async_integration_setup, + dependencies=["mqtt"], + ), + ), + }, + "mqtt_statestream": { + "dependencies": {"mqtt"}, + "integration": mock_integration( + hass, + MockModule( + "mqtt_statestream", + async_setup=async_integration_setup, + dependencies=["mqtt"], + ), + ), + }, + "file_upload": { + "dependencies": {"http"}, + "integration": mock_integration( + hass, + MockModule( + "file_upload", + dependencies=["http"], + ), + ), + }, + "http": { + "dependencies": set(), + "integration": mock_integration( + hass, + MockModule("http", dependencies=[]), + ), + }, + } + + async def mock_async_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + """Mock integrations.""" + return {domain: integrations[domain]["integration"] for domain in domains} + + with patch( + "homeassistant.setup.loader.async_get_integrations", + side_effect=mock_async_get_integrations, + ), patch("homeassistant.config.async_process_component_config", return_value={}): + bootstrap.async_set_domains_to_be_loaded(hass, {integration}) + await bootstrap.async_setup_multi_components(hass, {integration}, {}) + await hass.async_block_till_done() + + for assertion in assertions: + assert assertion + + assert calls == ["mqtt", integration] + + assert ( + f"Dependency {integration} will wait for dependencies ['mqtt']" in caplog.text + ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 60b9a250c17..53602ec28ff 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -276,7 +276,7 @@ async def test_remove_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Mock setting up entry.""" - hass.config_entries.async_setup_platforms(entry, ["light"]) + await hass.config_entries.async_forward_entry_setups(entry, ["light"]) return True async def mock_unload_entry( @@ -2657,7 +2657,7 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: (config_entries.SOURCE_ZEROCONF, BaseServiceInfo()), ( config_entries.SOURCE_HASSIO, - HassioServiceInfo(config={}, name="Test", slug="test"), + HassioServiceInfo(config={}, name="Test", slug="test", uuid="1234"), ), ), ) @@ -3867,7 +3867,7 @@ async def test_task_tracking(hass: HomeAssistant) -> None: event = asyncio.Event() results = [] - async def test_task(): + async def test_task() -> None: try: await event.wait() results.append("normal") @@ -3875,9 +3875,14 @@ async def test_task_tracking(hass: HomeAssistant) -> None: results.append("background") raise + async def test_unload() -> None: + await event.wait() + results.append("on_unload") + + entry.async_on_unload(test_unload) entry.async_create_task(hass, test_task()) entry.async_create_background_task(hass, test_task(), "background-task-name") await asyncio.sleep(0) hass.loop.call_soon(event.set) - await entry._async_process_on_unload() - assert results == ["background", "normal"] + await entry._async_process_on_unload(hass) + assert results == ["on_unload", "background", "normal"] diff --git a/tests/test_core.py b/tests/test_core.py index 6167bf6a63b..4d7a93c2887 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -33,7 +33,7 @@ from homeassistant.const import ( __version__, ) import homeassistant.core as ha -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HassJob, HomeAssistant, State from homeassistant.exceptions import ( InvalidEntityFormatError, InvalidStateError, @@ -2081,3 +2081,26 @@ async def test_shutdown_does_not_block_on_shielded_tasks( # Cleanup lingering task after test is done sleep_task.cancel() + + +async def test_cancellable_hassjob(hass: HomeAssistant) -> None: + """Simulate a shutdown, ensure cancellable jobs are cancelled.""" + job = MagicMock() + + @ha.callback + def run_job(job: HassJob) -> None: + """Call the action.""" + hass.async_run_hass_job(job) + + timer1 = hass.loop.call_later( + 60, run_job, HassJob(ha.callback(job), cancel_on_shutdown=True) + ) + timer2 = hass.loop.call_later(60, run_job, HassJob(ha.callback(job))) + + await hass.async_stop() + + assert timer1.cancelled() + assert not timer2.cancelled() + + # Cleanup + timer2.cancel() diff --git a/tests/util/test_language.py b/tests/util/test_language.py new file mode 100644 index 00000000000..70c38a38f00 --- /dev/null +++ b/tests/util/test_language.py @@ -0,0 +1,192 @@ +"""Test Home Assistant language util methods.""" +from __future__ import annotations + +import pytest + +from homeassistant.const import MATCH_ALL +from homeassistant.util import language + + +def test_match_all() -> None: + """Test MATCH_ALL.""" + assert language.matches(MATCH_ALL, ["fr-Fr", "en-US", "en-GB"]) == [ + "fr-Fr", + "en-US", + "en-GB", + ] + + +def test_region_match() -> None: + """Test that an exact language/region match is preferred.""" + assert language.matches("en-GB", ["fr-Fr", "en-US", "en-GB"]) == [ + "en-GB", + "en-US", + ] + + +def test_no_match() -> None: + """Test that an empty list is returned when there is no match.""" + assert ( + language.matches( + "en-US", + ["de-DE", "fr-FR", "zh"], + ) + == [] + ) + + assert ( + language.matches( + "en", + ["de-DE", "fr-FR", "zh"], + ) + == [] + ) + + assert language.matches("en", []) == [] + + +def test_prefer_us_english() -> None: + """Test that U.S. English is preferred when no region is provided.""" + assert language.matches("en", ["en-GB", "en-US", "fr-FR"]) == [ + "en-US", + "en-GB", + ] + + +def test_country_preferred() -> None: + """Test that country hint disambiguates.""" + assert language.matches( + "en", + ["fr-Fr", "en-US", "en-GB"], + country="GB", + ) == [ + "en-GB", + "en-US", + ] + + +def test_country_preferred_over_family() -> None: + """Test that country hint is preferred over language family.""" + assert ( + language.matches( + "de", + ["de", "de-CH", "de-DE"], + country="CH", + )[0] + == "de-CH" + ) + assert ( + language.matches( + "de", + ["de", "de-CH", "de-DE"], + country="DE", + )[0] + == "de-DE" + ) + + +def test_language_as_region() -> None: + """Test that the language itself can be interpreted as a region.""" + assert language.matches( + "fr", + ["en-US", "en-GB", "fr-CA", "fr-FR"], + ) == [ + "fr-FR", + "fr-CA", + ] + + +def test_zh_hant() -> None: + """Test that the zh-Hant matches HK or TW.""" + assert language.matches( + "zh-Hant", + ["en-US", "en-GB", "zh-CN", "zh-HK"], + ) == [ + "zh-HK", + "zh-CN", + ] + + assert language.matches( + "zh-Hant", + ["en-US", "en-GB", "zh-CN", "zh-TW"], + ) == [ + "zh-TW", + "zh-CN", + ] + + +@pytest.mark.parametrize("target", ["zh-Hant", "zh-Hans"]) +def test_zh_with_country(target: str) -> None: + """Test that the zh-Hant/zh-Hans still matches country when provided.""" + supported = ["en-US", "en-GB", "zh-CN", "zh-HK", "zh-TW"] + assert ( + language.matches( + target, + supported, + country="TW", + )[0] + == "zh-TW" + ) + assert ( + language.matches( + target, + supported, + country="HK", + )[0] + == "zh-HK" + ) + assert ( + language.matches( + target, + supported, + country="CN", + )[0] + == "zh-CN" + ) + + +def test_zh_hans() -> None: + """Test that the zh-Hans matches CN first.""" + assert language.matches( + "zh-Hans", + ["en-US", "en-GB", "zh-CN", "zh-HK", "zh-TW"], + ) == [ + "zh-CN", + "zh-HK", + "zh-TW", + ] + + +def test_zh_no_code() -> None: + """Test that the zh defaults to CN first.""" + assert language.matches( + "zh", + ["en-US", "en-GB", "zh-CN", "zh-HK", "zh-TW"], + ) == [ + "zh-CN", + "zh-HK", + "zh-TW", + ] + + +def test_es_419() -> None: + """Test that the es-419 matches es dialects.""" + assert language.matches( + "es-419", + ["en-US", "en-GB", "es-CL", "es-US", "es-ES"], + ) == [ + "es-ES", + "es-CL", + "es-US", + ] + + +def test_sr_latn() -> None: + """Test that the sr_Latn matches sr dialects.""" + assert language.matches( + "sr-Latn", + ["en-US", "en-GB", "sr-CS", "sr-RS"], + ) == [ + "sr-CS", + "sr-RS", + ] diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py new file mode 100644 index 00000000000..4d43859cc44 --- /dev/null +++ b/tests/util/test_ssl.py @@ -0,0 +1,53 @@ +"""Test Home Assistant ssl utility functions.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.util.ssl import ( + SSL_CIPHER_LISTS, + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) + + +@pytest.fixture +def mock_sslcontext(): + """Mock the ssl lib.""" + ssl_mock = MagicMock(set_ciphers=Mock(return_value=True)) + return ssl_mock + + +def test_client_context(mock_sslcontext) -> None: + """Test client context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + client_context() + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_called_with( + SSL_CIPHER_LISTS[SSLCipherList.MODERN] + ) + + client_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_called_with( + SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] + ) + + +def test_no_verify_ssl_context(mock_sslcontext) -> None: + """Test no verify ssl context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + create_no_verify_ssl_context() + mock_sslcontext.set_ciphers.assert_not_called() + + create_no_verify_ssl_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_called_with( + SSL_CIPHER_LISTS[SSLCipherList.MODERN] + ) + + create_no_verify_ssl_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_called_with( + SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] + ) diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 01aa1256fd6..44b287bd05d 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -457,6 +457,25 @@ def test_get_unit_system_invalid(key: str) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_metric_converted_unit_( @@ -657,6 +676,25 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_us_converted_unit(